End-to-End CI/CD: GitHub Actions → Cloud Build → Cloud Run

Many teams use GitHub Actions for CI but struggle to integrate it cleanly with GCP deployments. This guide shows two approaches — pure GitHub Actions deployment and a hybrid model where GitHub Actions triggers Cloud Build — with secure Workload Identity Federation so you never store GCP service account keys.

GitHub Actions is where most development teams live. Cloud Build is Google's native CI system with deep GCP integration. Cloud Run is the easiest way to deploy containerized workloads on GCP. Getting all three working together cleanly requires solving two problems: how to authenticate GitHub Actions to GCP securely (without service account key files), and which tasks should run in GitHub Actions vs. Cloud Build.

This guide covers both approaches — using GitHub Actions to deploy directly to Cloud Run, and using GitHub Actions to trigger Cloud Build which handles the GCP side. We'll use Workload Identity Federation throughout, which eliminates the need for long-lived service account keys in GitHub secrets.

Setting Up Workload Identity Federation

Workload Identity Federation lets external systems (like GitHub Actions) authenticate to GCP using short-lived tokens instead of static key files. When a GitHub Actions workflow runs, it presents a JWT from GitHub's OIDC endpoint. GCP exchanges that JWT for a GCP access token, scoped to a specific service account.

Creating the Workload Identity Pool

# Enable required APIs
gcloud services enable iamcredentials.googleapis.com
gcloud services enable sts.googleapis.com

# Create the Workload Identity Pool
gcloud iam workload-identity-pools create github-actions-pool   --location=global   --display-name="GitHub Actions Pool"   --description="Pool for GitHub Actions workflows"

# Create the OIDC Provider within the pool
gcloud iam workload-identity-pools providers create-oidc github-actions-provider   --location=global   --workload-identity-pool=github-actions-pool   --display-name="GitHub Actions Provider"   --issuer-uri="https://token.actions.githubusercontent.com"   --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository"   --attribute-condition="assertion.repository=='my-org/my-repo'"

Creating the Service Account and Binding

# Create a service account for GitHub Actions to use
gcloud iam service-accounts create github-actions-sa   --display-name="GitHub Actions Service Account"   --project=my-project

# Get the pool resource name
POOL_ID=$(gcloud iam workload-identity-pools describe github-actions-pool   --location=global   --format='value(name)')

# Allow GitHub Actions to impersonate this service account
gcloud iam service-accounts add-iam-policy-binding   github-actions-sa@my-project.iam.gserviceaccount.com   --role=roles/iam.workloadIdentityUser   --member="principalSet://iam.googleapis.com/${POOL_ID}/attribute.repository/my-org/my-repo"

# Grant the service account permission to deploy Cloud Run
gcloud projects add-iam-policy-binding my-project   --member="serviceAccount:github-actions-sa@my-project.iam.gserviceaccount.com"   --role="roles/run.admin"

# Grant permission to push to Artifact Registry
gcloud projects add-iam-policy-binding my-project   --member="serviceAccount:github-actions-sa@my-project.iam.gserviceaccount.com"   --role="roles/artifactregistry.writer"

# Grant permission to act as the Cloud Run service's service account
gcloud iam service-accounts add-iam-policy-binding   cloud-run-sa@my-project.iam.gserviceaccount.com   --member="serviceAccount:github-actions-sa@my-project.iam.gserviceaccount.com"   --role="roles/iam.serviceAccountUser"

Get the Provider Resource Name for GitHub

# You'll need this for the GitHub Actions workflow
gcloud iam workload-identity-pools providers describe github-actions-provider   --workload-identity-pool=github-actions-pool   --location=global   --format='value(name)'
# Output: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider

Approach 1: Pure GitHub Actions Deployment

For simpler setups, deploy directly from GitHub Actions without involving Cloud Build. This works well for Cloud Run services where you want GitHub Actions to own the full pipeline.

# .github/workflows/deploy.yml
name: Build and Deploy to Cloud Run

on:
  push:
    branches: [main]

env:
  PROJECT_ID: my-project
  REGION: europe-west4
  SERVICE: payment-api
  REGISTRY: europe-west4-docker.pkg.dev
  REPOSITORY: my-app

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Set up Python
      uses: actions/setup-python@v5
      with:
        python-version: '3.11'
        cache: 'pip'

    - name: Install dependencies
      run: pip install -r requirements-test.txt

    - name: Run unit tests
      run: pytest tests/unit/ -v --tb=short --junitxml=junit.xml

    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: always()
      with:
        name: test-results
        path: junit.xml

  build-deploy:
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write  # Required for Workload Identity Federation

    steps:
    - uses: actions/checkout@v4

    - name: Authenticate to Google Cloud
      uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider'
        service_account: 'github-actions-sa@my-project.iam.gserviceaccount.com'

    - name: Set up Cloud SDK
      uses: google-github-actions/setup-gcloud@v2

    - name: Configure Docker
      run: gcloud auth configure-docker ${{ env.REGISTRY }} --quiet

    - name: Build Docker image
      run: |
        docker build           --tag "${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}"           --tag "${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:latest"           --cache-from "${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:latest"           .

    - name: Push to Artifact Registry
      run: |
        docker push "${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}"
        docker push "${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:latest"

    - name: Deploy to Cloud Run
      uses: google-github-actions/deploy-cloudrun@v2
      with:
        service: ${{ env.SERVICE }}
        region: ${{ env.REGION }}
        image: '${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.SERVICE }}:${{ github.sha }}'
        flags: |
          --service-account=cloud-run-sa@my-project.iam.gserviceaccount.com
          --max-instances=20
          --min-instances=1
          --cpu=2
          --memory=1Gi
          --concurrency=80
          --port=8080

    - name: Post deployment URL
      run: |
        SERVICE_URL=$(gcloud run services describe ${{ env.SERVICE }}           --region=${{ env.REGION }}           --format='value(status.url)')
        echo "Deployed to: $SERVICE_URL"
        echo "SERVICE_URL=$SERVICE_URL" >> $GITHUB_ENV

Approach 2: GitHub Actions Triggers Cloud Build

For more complex workflows — multi-service deployments, GKE deployments, or when you want Cloud Build's native GCP integration — use GitHub Actions to trigger Cloud Build:

# .github/workflows/trigger-build.yml
name: Trigger Cloud Build

on:
  push:
    branches: [main]

jobs:
  trigger:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write

    steps:
    - uses: actions/checkout@v4

    - name: Authenticate to Google Cloud
      uses: google-github-actions/auth@v2
      with:
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-actions-provider'
        service_account: 'github-actions-sa@my-project.iam.gserviceaccount.com'

    - name: Trigger Cloud Build
      run: |
        gcloud builds submit           --no-source           --config=-           --region=europe-west4           <<'EOF'
        steps:
        - name: 'gcr.io/cloud-builders/git'
          args: ['clone', '--branch=main', 'https://github.com/my-org/my-repo.git', '.']
        - name: 'gcr.io/cloud-builders/docker'
          args: ['build', '-t', 'europe-west4-docker.pkg.dev/$PROJECT_ID/my-app/api:$COMMIT_SHA', '.']
        - name: 'gcr.io/cloud-builders/docker'
          args: ['push', 'europe-west4-docker.pkg.dev/$PROJECT_ID/my-app/api:$COMMIT_SHA']
        EOF

    - name: Wait for and monitor build
      run: |
        # Get the latest build ID
        BUILD_ID=$(gcloud builds list --region=europe-west4 --limit=1 --format='value(id)')
        echo "Watching build: $BUILD_ID"

        # Stream the build logs
        gcloud builds log $BUILD_ID --region=europe-west4 --stream

Cloud Run Deployment with Traffic Splitting

For zero-downtime deployments to Cloud Run, use traffic splitting to gradually shift traffic to the new revision:

# Deploy new revision without sending traffic to it
gcloud run deploy payment-api   --image=europe-west4-docker.pkg.dev/my-project/my-app/api:$COMMIT_SHA   --region=europe-west4   --no-traffic   --tag=canary

# Send 10% of traffic to the new revision
gcloud run services update-traffic payment-api   --region=europe-west4   --to-tags=canary=10

# After validating metrics, shift to 100%
gcloud run services update-traffic payment-api   --region=europe-west4   --to-latest

GKE Deployments from GitHub Actions

For GKE deployments, get cluster credentials and apply manifests:

    - name: Get GKE credentials
      uses: google-github-actions/get-gke-credentials@v2
      with:
        cluster_name: production
        location: europe-west4

    - name: Deploy to GKE
      run: |
        # Update the image in the deployment
        kubectl set image deployment/payment-api           api=europe-west4-docker.pkg.dev/my-project/my-app/api:${{ github.sha }}           -n production

        # Wait for rollout to complete
        kubectl rollout status deployment/payment-api           -n production           --timeout=5m

        # Verify pods are healthy
        kubectl get pods -n production -l app=payment-api

Environment-Specific Deployments

Use GitHub Environments for deployment protection:

  deploy-staging:
    needs: build
    runs-on: ubuntu-latest
    environment: staging  # Links to GitHub Environment settings
    permissions:
      contents: read
      id-token: write
    steps:
      # ... deploy to staging

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval in GitHub
    permissions:
      contents: read
      id-token: write
    steps:
      # ... deploy to production

In GitHub repository settings, configure the production environment to require reviewers before any deployment. This creates a manual approval gate without needing Cloud Deploy.

Security Best Practices

Never store GCP credentials in GitHub Secrets: Workload Identity Federation eliminates this need entirely. If you inherited a repository with a GCP_SA_KEY secret, remove it and migrate to WIF.

Scope service account permissions narrowly: The github-actions-sa should only have the minimum permissions needed. For Cloud Run deployments: roles/run.admin + roles/artifactregistry.writer + roles/iam.serviceAccountUser (to use the Cloud Run service's SA).

Use repository-level attribute conditions: The attribute-condition="assertion.repository=='my-org/my-repo'" in the WIF provider ensures only your specific repo can authenticate. Without this, anyone in your organization could use the pool.

For container security scanning integrated into this pipeline, see our GKE security hardening guide. For the Cloud Build-native approach to CD with Cloud Deploy, see our GCP DevOps guide.