Push code with GitHub Actions to Google Cloud’s Artifact Registry
Published: 2024-04-10 17:17:29

> _I originally published this post on [Medium](https://roger-that-dev.medium.com/push-code-with-github-actions-to-google-clouds-artifact-registry-60d256f8072f), March 20, 2024_ 

If you’ve got some application code sitting in GitHub and need to push it to a Docker registry, there are a lot of options. This article is going to cover just one: **pushing to Google Cloud Artifact Registry using GitHub Actions**. And we’re going to make it happen **without using service account keys** by leveraging [Google Cloud Workload Identity Federation](https://cloud.google.com/iam/docs/workload-identity-federation).

## Where to begin

### Some things we need first

To follow along with what I’m going to demonstrate, you’ll need a couple of things:

☑️ **A Google Cloud project:** this will be the project where we’ll be creating our resources, which includes an Artifact Registry repository and some IAM resources. Enable the [IAM Service Account Credentials](https://console.cloud.google.com/apis/api/iamcredentials.googleapis.com) and [Artifact Registry](https://console.cloud.google.com/apis/api/artifactregistry.googleapis.com) APIs on this project.

☑️ **Google Cloud CLI:** we’ll be using the `gcloud` CLI to interact with Google Cloud. If you don’t want to install it on your local machine, you can use the [Cloud Shell](https://shell.cloud.google.com/) in the Google Cloud console which has `gcloud` built-in.

☑️ **A GitHub repository:** you’ll need a GitHub repository with a `Dockerfile` so that you can build and push your container image to the Artifact Registry.

### GitHub Actions workflow file

GitHub Action workflows are typically stored as `yaml` files in the `.github/workflows/` directory of your repo. Here’s the GitHub Action workflow file we’re working with:

```yaml
name: Push to Artifact Registry

on:
    push:
      branches: [ "main" ]

env:
  IMAGE_NAME: ''
  PROJECT_ID: ''
  AR_REPO_LOCATION: ''
  AR_URL: ''
  SERVICE_ACCOUNT: '' 
  WORKLOAD_IDENTITY_PROVIDER: ''

jobs:
  push_to_ar:
    permissions:
      contents: 'read'
      id-token: 'write'

    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Google Auth
        id: auth
        uses: 'google-github-actions/auth@v2'
        with:
          token_format: 'access_token'
          project_id: ${{ env.PROJECT_ID }}
          service_account: ${{ env.SERVICE_ACCOUNT }}
          workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}

      - name: Docker Auth
        id: docker-auth
        uses: 'docker/login-action@v1'
        with:
          username: 'oauth2accesstoken'
          password: '${{ steps.auth.outputs.access_token }}'
          registry: '${{ env.AR_REPO_LOCATION }}-docker.pkg.dev'

      - name: Build and Push Container
        run: |-
          docker build -t "${{ env.AR_URL }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" ./
          docker push "${{ env.AR_URL }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
```

This workflow will authenticate with Google Cloud using the [Google Cloud auth GitHub Action](https://github.com/google-github-actions/auth) and use Docker to authenticate and push to the registry. To make this workflow work (or flow?) we need to set up some Google Cloud resources and add in those values for our environment variables. Make sure to add in the value for `PROJECT_ID` where you have permission to create resources. The value for `IMAGE_NAME` can be anything — it’ll be created the first time this workflow runs:

```yaml
...
env:
  IMAGE_NAME: 'my-app-image'
  PROJECT_ID: 'my-project-id'
  AR_REPO_LOCATION: ''
  AR_URL: ''
  SERVICE_ACCOUNT: '' 
  WORKLOAD_IDENTITY_PROVIDER: ''
...
```
Good start! Next we’ll need an Artifact Registry repository.

## Artifact Registry repository 

Artifact Registry is Google Cloud's build artifact management solution. It supports several types of repositories like apt, Maven, and Python. For our purposes, we want a **Docker** repository. You can use an existing one, or create a new one with this `gcloud` command:

```sh 
gcloud artifacts repositories create my-ar-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="Example Docker repository"
```
This will create a Docker repository called `my-ar-repo` in the `us-central1` region. You can see it in the console on the [Artifact Registry repositories page](https://console.cloud.google.com/artifacts). For our workflow `YAML` file, we need the **Artifact Registry URL**. Let's retrieve that with the following command:

```sh
gcloud artifacts repositories describe my-ar-repo --location=us-central1
```
The output will look like this:
```
Encryption: Google-managed key
Registry URL: us-central1-docker.pkg.dev/my-project-id/my-ar-repo
Repository Size: 5026.459MB
createTime: '2024-03-13T23:19:57.701232Z'
description: Example Docker repository
format: DOCKER
mode: STANDARD_REPOSITORY
name: projects/my-project-id/locations/us-central1/repositories/my-ar-repo
updateTime: '2024-03-18T17:08:49.658081Z'
```
Let’s take that value for the `Registry URL` and throw it into our GitHub Actions workflow file, along with the repo location:

```
...
env:
  IMAGE_NAME: 'my-app-image'
  PROJECT_ID: 'my-project-id'
  AR_REPO_LOCATION: 'us-central1'
  AR_URL: 'us-central1-docker.pkg.dev/my-project-id/my-ar-repo'
  SERVICE_ACCOUNT: '' 
  WORKLOAD_IDENTITY_PROVIDER: ''
...
```

## Service account

Next, we need a service account to serve as the identity that’s going to be doing the pushing to the Artifact Registry repository. Create a service account with `gcloud`:
```sh
gcloud iam service-accounts create github-actions-service-account \
 --description="A service account for use in a GitHub Actions workflow" \
 --display-name="GitHub Actions service account."
```

Now that we have a service account (see it [in the console under IAM](http://console.cloud.google.com/iam-admin/serviceaccounts)), let’s add the service account email to our `YAML` file. The format is `SERVICE_ACCT_NAME@PROJECT_ID.iam.gserviceaccount.com`:

```yaml
...
env:
  IMAGE_NAME: 'my-app-image'
  PROJECT_ID: 'my-project-id'
  AR_REPO_LOCATION: 'us-central1'
  AR_URL: 'us-central1-docker.pkg.dev/my-project-id/my-ar-repo'
  SERVICE_ACCOUNT: 'github-actions-service-account@my-project-id.iam.gserviceaccount.com' 
  WORKLOAD_IDENTITY_PROVIDER: ''
...
```

By default, the service account has no permissions on any resources on the project. We only need it to be able to push builds to our Artifact Registry repo. Let’s make it so:

```sh
gcloud artifacts repositories add-iam-policy-binding my-ar-repo \
  --location=us-central1 \
  --role=roles/artifactregistry.createOnPushWriter \
  --member=serviceAccount:github-actions-service-account@my-project-id.iam.gserviceaccount.com
```

The command above grants the _**Artifact Registry Create-on-Push Writer**_ IAM role to our service account, but only for our particular Artifact Registry repository. We could grant it on the entire project by adding the IAM binding to the _project IAM policy_, but since Artifact Registry repos have their own IAM policies, we can practice some least privilege and narrow the scope to just the one we’re working with.

With that, our service account is able to push builds to our Artifact Registry. Well not really — now we need a way to tell it to do that.

> _Note: If the application image already existed in our repository, then **roles/artifactregistry.writer** would suffice. We’re using **roles/artifactregistry.createOnPushWriter** instead because on the first push, it needs to be able to create the initial image for our application._

## Workload Identity Federation

Historically, one way to programmatically access resources with a service account has been to use JSON keys to authenticate with their credentials. That’s still possible, but it is highly discouraged due to the security risk associated with managing them. You’ll see caution signs about that throughout Google Cloud console and documentation:

![TLDR: service account keys bad](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/scw6xaxk3bfohrnjsoiu.png)

**_Workload Identity Federation_** offers an alternative. It lets us grant external identities the ability to impersonate a service account without the burden of service account keys. Our goal is to create a **_workload identity pool provider_** for our GitHub repository so that we can impersonate our service account from a GitHub Actions workflow.

### Create a workload identity pool

Before we can create a provider, we need to create a **_workload identity pool_**. A workload identity pool is a Google Cloud resource that is used to manage external identities. Google Cloud suggests creating a new pool for each non-Google Cloud environment that needs to access resources in our project. For our case, let’s create a workload identity pool for our app’s development environment:

```sh
gcloud iam workload-identity-pools create "my-app-dev-pool" \
  --project=PROJECT_ID \
  --location=global \
  --display-name="Identity pool for my test app"
```

This creates a workload identity pool on your project. See it [in the console under IAM](https://console.cloud.google.com/iam-admin/workload-identity-pools).


![Workload identity pool in Cloud console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e4oee9b25fcuppbaj0q0.png)

### Create a workload identity pool provider

A **_workload identity pool provider_** describes the relationship between Google Cloud and an identity provider (IdP) that supports OpenID Connect (OIDC). It must be created within a workload identity pool:

```sh
gcloud iam workload-identity-pools providers create-oidc "github-actions-provider" \
 --location="global" \
 --workload-identity-pool="my-app-dev-pool" \
 --display-name="Provider for GitHub Actions" \
 --issuer-uri="https://token.actions.githubusercontent.com" \
 --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner"
```

The `create-oidc` sub-command indicates that we want to create an _**OpenID Connect (OIDC)**_ provider in our pool. GitHub uses OIDC to authenticate with different cloud providers (see [GitHub documentation on it here](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-cloud-providers)). The `issuer-uri` parameter specifies the provider URL, as indicated by [GitHub’s OIDC documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#adding-the-identity-provider-to-aws).

The **`--attribute-mapping`** parameter lists our _**attribute mapping**_. Attribute mapping defines how values are derived from an external token and mapped to the Google Security Token Service (STS) token attributes. The value for this parameter is a comma-separated list of mappings in the form of **`TARGET_ATTRIBUTE=SOURCE_EXPRESSION`**. These attributes will be referenced later when we set up permissions.

> _Learn more about attribute mapping from [Google Cloud’s documentation on workload identity federation](https://cloud.google.com/iam/docs/workload-identity-federation#mapping)._

With a workload identity pool provider in place we can add the value to our workflow `YAML` file. First, retrieve the full name of the provider:
```sh
gcloud iam workload-identity-pools providers describe github-actions-provider \
  --location=global \
  --workload-identity-pool="my-app-dev-pool"
```

The output will include the name which will be in the format `projects/PROJECT_NUMBER/locations/POOL_LOCATION/workloadIdentityPools/POOL_NAME/providers/PROVIDER_NAME`. That’s what we need for the `WORKLOAD_IDENTITY_PROVIDER` environment variable:

```yaml
...
env:
  IMAGE_NAME: 'my-app-image'
  PROJECT_ID: 'my-project-id'
  AR_REPO_LOCATION: 'us-central1'
  AR_URL: 'us-central1-docker.pkg.dev/my-project-id/my-ar-repo'
  SERVICE_ACCOUNT: 'github-actions-service-account@my-project-id.iam.gserviceaccount.com' 
  WORKLOAD_IDENTITY_PROVIDER: 'projects/123456789/locations/global/workloadIdentityPools/my-app-dev-pool/providers/github-actions-provider'
...
```

## Setting permissions

The only permissions we’ve granted so far have been to our service account to push to the Artifact Registry repository. Now that we have a workload identity provider, we need to grant it permission to act as the service account.

If we wanted to grant service account impersonation permissions to a user account or service account principal, we would grant the **_Service Account Token Creator_** role on the service account’s IAM policy:

```sh
gcloud iam service-accounts add-iam-policy-binding \
  github-actions-service-account@my-project-id.iam.gserviceaccount.com \
  --role=roles/iam.serviceAccountTokenCreator \
  --member=user:roger@myemail.com
```

This command would allow my user account to act as the service account and access whatever the service account can access. With Workload Identity Federation, it’s the same concept with 2 differences: the IAM role and the principal (or the `member` parameter in the command above).

Instead of granting the **_Service Account Token Creator_** role (`roles/iam.serviceAccountTokenCreator`) to our workload identity pool principal, we need to grant the _**Workload Identity User**_ role (`roles/iam.workloadIdentityUser`) instead. This role allows the principal to impersonate service accounts from federated workloads.

As for the principal, the prefixes `user` and `serviceAccount` are used for user accounts and service accounts, respectively, followed by an email address:

`user:roger@myemail.com`

`serviceAccount:my-service-account@my-project-id.iam.gserviceaccount.com`

Workload Identity Federation however uses `principalSet` as a prefix, followed by a _member identifier_ that includes the identity pool and an attribute from the provider’s attribute mapping:

`principalSet://iam.googleapis.com/WORKLOAD_IDENTITY_POOL_NAME/attribute.ATTRIBUTE_NAME/ATTRIBUTE_VALUE`

For example, the following principal can be used to grant access to external identities coming from any GitHub repository that your organization owns:

`principalSet://iam.googleapis.com/WORKLOAD_IDENTITY_POOL_NAME/attribute.owner/your-github-organization`

We can use any of the attributes that we mapped on our workload identity federation OIDC provider earlier. The principal that we’ll be using, however, will narrow access down to external identities corresponding with one particular GitHub repository using `attribute.repository`:

`principalSet://iam.googleapis.com/WORKLOAD_IDENTITY_POOL_NAME/attribute.repository/github-repo-owner/github-repo-name`

> _Note: Besides attributes, you can grant permissions to external identities by **subject** and by **group**. Learn more about that [here](https://cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#allow_the_external_workload_to_impersonate_the_service_account)._

So we’ve got a IAM role and a principal to grant it to. Let’s add an IAM binding to our service account’s IAM policy. First, retrieve the workload identity pool name:
```sh
gcloud iam workload-identity-pools describe "my-app-dev-pool" \
  --location=global
```

The value will be in the format `projects/PROJECT_NUMBER/locations/POOL_LOCATION/workloadIdentityPools/POOL_NAME`
It’s a long string, so let’s throw this into an environment variable:

```sh
export WIP_POOL=projects/123456789/locations/global/workloadIdentityPools/my-app-dev-pool
```

And finally, let’s add the binding to the service account’s IAM policy. Be sure to update the values for `GITHUB_REPO_OWNER` and `GITHUB_REPO_NAME` with values for the GitHub repo that you plan on the running the workflow from:

```sh
gcloud iam service-accounts add-iam-policy-binding \
  github-actions-service-account@my-project-id.iam.gserviceaccount.com \
  --role=roles/iam.workloadIdentityUser \
  --member=principalSet://iam.googleapis.com/${WIP_POOL}/attribute.repository/GITHUB_REPO_OWNER/GITHUB_REPO_NAME
```

With this binding in place, GitHub Actions is able to use Workload Identity Federation to authenticate as your service account and push builds to your Artifact Registry. There’s one more thing we can do to further narrow the scope of access: specify an _**attribute condition**_ on the identity pool provider.

An **_attribute condition_** is an expression that checks an attribute and must evaluate to true for a given credential in order for it to be accepted. For example, we can define an attribute condition that checks whether the attribute `repository_owner` matches a GitHub organization or GitHub user that owns a repository:

```sh
gcloud iam workload-identity-pools providers update-oidc \
  github-actions-provider \
  --project=PROJECT_ID \
  --location=global \
  --workload-identity-pool=my-app-dev-pool \
  --attribute-condition="assertion.repository_owner == 'GITHUB_REPO_OWNER'"
```

In this example, the restriction added by this attribute condition is redundant, since the IAM policy binding is already limited to a particular repo owner. However, if for whatever reason the IAM policy ever changes to be more broad, this will still restrict access to the specified GitHub repo owner.

## Let's test drive

Before we can test this out, you’ll need a repository with a valid `Dockerfile`.

Here’s the final `YAML` file for our workflow:

```yaml
name: Push to Artifact Registry

on:
    push:
      branches: [ "main" ]

env:
  IMAGE_NAME: 'my-app-image'
  PROJECT_ID: 'my-project-id'
  AR_REPO_LOCATION: 'us-central1'
  AR_URL: 'us-central1-docker.pkg.dev/my-project-id/my-ar-repo'
  SERVICE_ACCOUNT: 'github-actions-service-account@my-project-id.iam.gserviceaccount.com' 
  WORKLOAD_IDENTITY_PROVIDER: 'projects/123456789/locations/global/workloadIdentityPools/my-app-dev-pool/providers/github-actions-provider'

jobs:
  push_to_ar:
    permissions:
      contents: 'read'
      id-token: 'write'

    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Google Auth
        id: auth
        uses: 'google-github-actions/auth@v2'
        with:
          token_format: 'access_token'
          project_id: ${{ env.PROJECT_ID }}
          service_account: ${{ env.SERVICE_ACCOUNT }}
          workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }}

      - name: Docker Auth
        id: docker-auth
        uses: 'docker/login-action@v1'
        with:
          username: 'oauth2accesstoken'
          password: '${{ steps.auth.outputs.access_token }}'
          registry: '${{ env.AR_REPO_LOCATION }}-docker.pkg.dev'

      - name: Build and Push Container
        run: |-
          docker build -t "${{ env.AR_URL }}/${{ env.IMAGE_NAME }}:${{ github.sha }}" ./
          docker push "${{ env.AR_URL }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"
```

We’ll place this configuration in our GitHub repository in `.github/workflows/push-to-ar.yml`. As soon as you merge this file into your main branch, it’ll kick off the workflow and if all permissions and resources are in place, you’ll have a build of your application sitting in your Artifact Registry. Verify by visiting the [Artifact Registry page in the Google Cloud Console.](https://console.cloud.google.com/artifacts)

## What next?

Arguably, the star of the show is the [Google Auth GitHub Action](https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication-from-github-actions) - that’s what’s using the Workload Identity Provider and service account to access your Google Cloud project. If you plan on doing other things with this authentication, just be sure to use a service account that has the right access those things. For example, if you’re using the [Secret Manager GitHub Action](https://github.com/google-github-actions/get-secretmanager-secrets) to access secrets from Secret Manager, make sure the service account has the _**Secret Manager Secret Accessor**_ IAM role on the project or secret. To take a look at all of the available actions, check out the repos available in the [Google GitHub Actions GitHub organization](https://github.com/orgs/google-github-actions/repositories).