GCP CI/CD — Cloud Build, Artifact Registry, Cloud Deploy

0/0 checks

CI/CD Service Map

Stage AWS GCP
Build CodeBuild Cloud Build
Container registry ECR Artifact Registry
Delivery / deploy CodeDeploy + CodePipeline Cloud Deploy
Source repo CodeCommit Cloud Source Repositories (or GitHub/GitLab)
Secrets in pipeline Secrets Manager Secret Manager

Most GCP teams use GitHub Actions or Jenkins for CI and Cloud Build + Cloud Deploy for the GCP-native delivery step. Cloud Build alone handles most use cases without needing a full pipeline service.


Cloud Build

Cloud Build = AWS CodeBuild. Runs containerized build steps, triggered by source events (git push, PR, tag).

Build Config

Everything runs in a cloudbuild.yaml (equivalent to buildspec.yml):

# cloudbuild.yaml
steps:
  # Step 1: Run tests
  - name: 'python:3.11'
    entrypoint: pip
    args: ['install', '-r', 'requirements.txt']
  
  - name: 'python:3.11'
    entrypoint: pytest
    args: ['tests/', '-v', '--tb=short']
    env:
      - 'ENVIRONMENT=test'

  # Step 2: Build Docker image
  - name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
      - '-t'
      - 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:latest'
      - '.'

  # Step 3: Push to Artifact Registry
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', '--all-tags', 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app']

  # Step 4: Deploy to Cloud Run (simple deploy, no progressive delivery)
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'my-service'
      - '--image=us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'
      - '--region=us-central1'

# Built images (Cloud Build caches these)
images:
  - 'us-central1-docker.pkg.dev/$PROJECT_ID/my-repo/my-app:$SHORT_SHA'

# Timeout and machine type
timeout: '1200s'
options:
  machineType: 'E2_HIGHCPU_8'    # more CPU for faster builds
  logging: CLOUD_LOGGING_ONLY

Built-in Substitutions

# Available automatically in every build
$PROJECT_ID       # GCP project ID
$BUILD_ID         # unique build ID
$SHORT_SHA        # first 7 chars of git commit SHA
$COMMIT_SHA       # full git commit SHA
$BRANCH_NAME      # git branch
$TAG_NAME         # git tag (if triggered by tag push)
$REPO_NAME        # repo name

# Custom substitutions
substitutions:
  _DEPLOY_ENV: production
  _SERVICE_NAME: my-api
steps:
  - name: 'ubuntu'
    args: ['echo', 'Deploying ${_SERVICE_NAME} to ${_DEPLOY_ENV}']

What's the difference between $SHORT_SHA and $COMMIT_SHA, and which one does the Build Config example above actually use to tag images?

Triggers

# Trigger on push to main branch
gcloud builds triggers create github \
  --repo-name=my-repo \
  --repo-owner=my-org \
  --branch-pattern=^main$ \
  --build-config=cloudbuild.yaml

# Trigger on tag push (release)
gcloud builds triggers create github \
  --repo-name=my-repo \
  --repo-owner=my-org \
  --tag-pattern="v[0-9]+\.[0-9]+\.[0-9]+" \
  --build-config=cloudbuild-release.yaml

# Manual trigger for a specific commit
gcloud builds submit \
  --config=cloudbuild.yaml \
  --substitutions=SHORT_SHA=$(git rev-parse --short HEAD) \
  .

Accessing Secrets in Build

Two ways to get a Secret Manager value into a build step — pick one per step, not both.

Decode the secret inside a bash step and hand it to your own script. Works anywhere gcloud runs, but the plaintext exists as a shell variable for that step's whole lifetime.

steps:
  - name: 'gcr.io/cloud-builders/gcloud'
    entrypoint: 'bash'
    args:
      - '-c'
      - |
          DB_PASSWORD=$$(gcloud secrets versions access latest --secret=db-password)
          ./deploy.sh --password=$$DB_PASSWORD

Declare the secret once in availableSecrets, then reference it by name in secretEnv on whichever step needs it. Cloud Build injects it as an environment variable scoped to just that step — no explicit gcloud secrets versions access call in your script.

availableSecrets:
  secretManager:
    - versionName: projects/$PROJECT_ID/secrets/db-password/versions/latest
      env: 'DB_PASSWORD'

steps:

  • name: 'gcr.io/cloud-builders/gcloud' secretEnv: ['DB_PASSWORD'] script: | echo "Using password from Secret Manager: ${DB_PASSWORD:0:3}***"

Build Caching

# Cache dependencies between builds using GCS
steps:
  - name: 'gcr.io/cloud-builders/gsutil'
    args: ['cp', 'gs://my-build-cache/pip-cache.tar.gz', '/tmp/pip-cache.tar.gz']
    id: restore-cache

  - name: 'python:3.11'
    script: |
      tar xzf /tmp/pip-cache.tar.gz -C / 2>/dev/null || true
      pip install -r requirements.txt --cache-dir /root/.cache/pip
    waitFor: ['restore-cache']

  - name: 'gcr.io/cloud-builders/gsutil'
    args: ['cp', '-r', '/root/.cache/pip', 'gs://my-build-cache/pip-cache.tar.gz']
    waitFor: ['-']    # run after all steps

Why does this pattern round-trip the pip cache through a GCS bucket instead of just relying on the previous build reusing the same machine's disk?

Cloud Build vs AWS CodeBuild

Cloud Build AWS CodeBuild
Config file cloudbuild.yaml buildspec.yml
Build units "build steps" (each is a container) "phases" within one environment
Parallelism Steps can run in parallel with waitFor Phases are sequential
Machine types e2-medium to n1-highcpu-32 Small to 72 vCPU
Free tier 120 build-minutes/day 100 build-minutes/month
GitHub integration First-class First-class
Caching GCS-based or Docker layer cache S3-based or local cache
Cost $0.003/build-minute (n1-standard-1) $0.005/build-minute (general1.small)

Artifact Registry

Artifact Registry = AWS ECR + CodeArtifact. Stores Docker images, Helm charts, Maven/PyPI/npm packages.

# Create a Docker repository
gcloud artifacts repositories create my-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="Production images"

# Authenticate Docker to Artifact Registry
gcloud auth configure-docker us-central1-docker.pkg.dev

# Build and push
docker build -t us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0 .
docker push us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0

# Pull
docker pull us-central1-docker.pkg.dev/my-project/my-repo/my-app:v1.0.0

# List images
gcloud artifacts docker images list us-central1-docker.pkg.dev/my-project/my-repo

# Delete old images (cleanup)
gcloud artifacts docker images delete \
  us-central1-docker.pkg.dev/my-project/my-repo/my-app:old-tag

Vulnerability Scanning

# Enable automatic scanning on push
gcloud artifacts repositories update my-repo \
  --location=us-central1 \
  --enable-vulnerability-scanning

# View scan results
gcloud artifacts docker images list-vulnerabilities \
  us-central1-docker.pkg.dev/my-project/my-repo/my-app@sha256:abc123

Cleanup Policies (= ECR Lifecycle Policies)

# Auto-delete untagged images older than 14 days
gcloud artifacts repositories set-cleanup-policies my-repo \
  --location=us-central1 \
  --policy='[
    {
      "name": "delete-old-untagged",
      "action": "DELETE",
      "condition": {
        "tagState": "UNTAGGED",
        "olderThan": "1209600s"
      }
    }
  ]'

This cleanup policy's condition is scoped to tagState: UNTAGGED. If an old image still carries a tag nobody uses anymore, will this policy delete it after 14 days?

Helm Charts in Artifact Registry

# Create OCI-compatible Helm repo
gcloud artifacts repositories create helm-charts \
  --repository-format=docker \
  --location=us-central1

# Push Helm chart
helm package ./my-chart
helm push my-chart-1.0.0.tgz oci://us-central1-docker.pkg.dev/my-project/helm-charts

# Install from Artifact Registry
helm install my-release \
  oci://us-central1-docker.pkg.dev/my-project/helm-charts/my-chart \
  --version 1.0.0

Cloud Deploy — Progressive Delivery

Cloud Deploy = AWS CodeDeploy + CodePipeline. Manages delivery pipelines with stages (dev → staging → prod), approval gates, and built-in rollback.

graph TD
    classDef gcp fill:#4285f4,stroke:#2a56c6,color:#fff
    classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef amber fill:#e67e22,stroke:#d35400,color:#fff
    classDef gate fill:#9b59b6,stroke:#71368a,color:#fff
    classDef red fill:#e74c3c,stroke:#c0392b,color:#fff

    subgraph CI["Continuous Integration"]
        BUILD["Cloud Build<br/>runs tests, builds image,<br/>pushes to Artifact Registry"]:::gcp
        REL["gcloud deploy releases create<br/>new Cloud Deploy Release"]:::gcp
        BUILD --> REL
    end

    subgraph PIPE["Cloud Deploy — Delivery Pipeline"]
        DEV["dev target<br/>auto-deploy, no approval"]:::green
        STAGING["staging target<br/>auto-deploy, no approval"]:::amber
        GATE{"requireApproval: true<br/>rollout paused"}:::gate
        PROD["production target<br/>canary 10% → 25% → 50% → 100%<br/>verify: true at each step"]:::red
    end

    REL -->|"stage 1: deploy"| DEV
    DEV -->|"gcloud deploy releases promote"| STAGING
    STAGING -->|"gcloud deploy releases promote"| GATE
    GATE -->|"gcloud deploy rollouts approve"| PROD
Auto-deploys on every release with no gate at all — the fastest feedback loop, meant to catch build-level breakage right after a merge. No requireApproval, no canary strategy: the whole image rolls out at once.
Also auto-deploys with no human click required — but only once explicitly promoted from dev via gcloud deploy releases promote --to-target=staging. A release can sit in dev indefinitely without ever reaching staging.
The only target with requireApproval: true. Promotion creates the rollout, but it stays paused until gcloud deploy rollouts approve runs against that specific rollout ID. Once approved, it still doesn't jump to 100% — the canary strategy staggers it through 10% → 25% → 50%, with verify: true checking health at each step before continuing.

Delivery Pipeline Config

# clouddeploy.yaml
apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: my-app-pipeline
  location: us-central1
description: My App delivery pipeline
serialPipeline:
  stages:
    - targetId: dev
      profiles: [dev]
    - targetId: staging
      profiles: [staging]
    - targetId: production
      profiles: [production]
      strategy:
        canary:
          runtimeConfig:
            cloudRun:
              automaticTrafficControl: true
          canaryDeployment:
            percentages: [10, 25, 50]
            verify: true
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: dev
  location: us-central1
run:
  location: projects/my-project/locations/us-central1
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: production
  location: us-central1
requireApproval: true    # manual approval before deploy to prod
run:
  location: projects/my-project/locations/us-central1

Deploy Workflow

# Apply pipeline and target definitions
gcloud deploy apply --file=clouddeploy.yaml --region=us-central1

# Create a release (triggers deployment to dev)
gcloud deploy releases create release-$(date +%Y%m%d-%H%M) \
  --delivery-pipeline=my-app-pipeline \
  --region=us-central1 \
  --images=my-app=us-central1-docker.pkg.dev/my-project/my-repo/my-app:$SHORT_SHA

# Promote to staging (after dev passes)
gcloud deploy releases promote \
  --delivery-pipeline=my-app-pipeline \
  --region=us-central1 \
  --release=release-20240115-1430 \
  --to-target=staging

# Approve production deployment
gcloud deploy rollouts approve \
  my-app-pipeline-20240115-1430-to-production-0001 \
  --delivery-pipeline=my-app-pipeline \
  --region=us-central1 \
  --release=release-20240115-1430

# Rollback if needed
gcloud deploy rollouts rollback \
  my-app-pipeline \
  --region=us-central1 \
  --release=release-20240115-1430 \
  --to-target=production

Canary Rollout With Approval Gates, Step by Step

1. Create the release. gcloud deploy releases create registers a new release and immediately kicks off stage 1 — auto-deploy to the dev target. No approval needed here.
2. Promote to staging. Once dev looks healthy, gcloud deploy releases promote --to-target=staging triggers another unattended auto-deploy — same mechanics as dev, just a second environment.
3. Promote to production — and stop. Promoting to production creates a rollout, but because that Target has requireApproval: true, Cloud Deploy pauses it before anything reaches production traffic. Zero percent has moved.
4. Approve the gate. A human (or an automated check hitting the same API) runs gcloud deploy rollouts approve my-app-pipeline-...-to-production-0001. Nothing about promoting from staging approves production automatically — this is a separate, explicit action.
5. Canary ramps: 10% → 25% → 50%. Approval unpauses the rollout, which now walks the canaryDeployment.percentages list one step at a time. Because verify: true, each percentage's health is checked before the next jump — traffic never doubles onto an unverified step.
6. Full rollout, or rollback. If every canary step verifies clean, the rollout completes to 100%. If something regresses instead, gcloud deploy rollouts rollback ... --to-target=production reverts production to the previous release without touching dev or staging.

Only the production Target sets requireApproval: true — dev and staging don't. What actually changes about the rollout process for production versus the other two stages?


Full CI/CD Pipeline Pattern

graph TD
    classDef trigger fill:#4285f4,stroke:#2a56c6,color:#fff
    classDef build fill:#34495e,stroke:#212f3c,color:#fff
    classDef green fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef amber fill:#e67e22,stroke:#d35400,color:#fff
    classDef gate fill:#9b59b6,stroke:#71368a,color:#fff
    classDef red fill:#e74c3c,stroke:#c0392b,color:#fff

    PUSH["Developer pushes to main"]:::trigger --> TRIGGER["Cloud Build trigger fires"]:::trigger

    subgraph CI["Cloud Build"]
        TEST["Run tests<br/>pytest / go test / jest"]:::build
        DOCKER["Build Docker image"]:::build
        PUSHAR["Push to Artifact Registry"]:::build
        SCAN["Run vulnerability scan"]:::build
        RELEASE["Create Cloud Deploy release"]:::build
        TEST --> DOCKER --> PUSHAR --> SCAN --> RELEASE
    end

    TRIGGER --> TEST

    RELEASE --> DEV["Auto-deploy to dev<br/>smoke test / integration test"]:::green
    DEV --> STAGING["Promote to staging<br/>manual QA or automated regression"]:::amber
    STAGING --> APPROVAL{"Approval gate<br/>Jira ticket / PR approval"}:::gate
    APPROVAL --> PROD["Deploy to production<br/>canary 10% → 25% → 50% → 100%"]:::red
    PROD --> MONITOR{"Monitor error rate<br/>for 10 min"}:::gate
    MONITOR -->|"healthy"| FULL["Full rollout complete"]:::green
    MONITOR -->|"regression"| ROLLBACK["Automatic rollback"]:::red

The vulnerability scan step runs after "Push to Artifact Registry," not before it. What does that ordering mean the scan alone can and can't prevent?


Cloud Build vs GitHub Actions

Most teams use GitHub Actions for CI and Cloud Build for GCP-specific deploy steps. Here's the integration:

# .github/workflows/deploy.yml — uses gcloud in GHA
name: Deploy to GCP

on:
  push:
    branches: [main]

permissions:
  id-token: write    # for Workload Identity Federation (no service account keys)
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      # Authenticate to GCP via Workload Identity (no keys needed)
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/github'
          service_account: 'github-actions@my-project.iam.gserviceaccount.com'
      
      - uses: google-github-actions/setup-gcloud@v2
      
      - name: Configure Docker
        run: gcloud auth configure-docker us-central1-docker.pkg.dev
      
      - name: Build and push
        run: |
          docker build -t us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }} .
          docker push us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }}
      
      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy my-service \
            --image=us-central1-docker.pkg.dev/my-project/my-repo/my-app:${{ github.sha }} \
            --region=us-central1

GCP Workload Identity Federation for GitHub Actions = AWS OIDC provider in IAM. No service account keys in GitHub secrets.

Workload Identity Federation: The Token Exchange

permissions: id-token: write and google-github-actions/auth@v2 aren't just boilerplate — they drive an actual token exchange, not a stored credential:

sequenceDiagram
    participant GH as GitHub Actions job
    participant OIDC as GitHub OIDC provider
    participant WIF as GCP Workload Identity Pool
    participant SA as github-actions@ service account
    participant API as GCP APIs — Artifact Registry, Cloud Run

    Note over GH: workflow declares permissions, id-token: write
    GH->>OIDC: request short-lived OIDC ID token for this run
    OIDC-->>GH: signed JWT with repo, branch, run claims
    GH->>WIF: present JWT to the configured workload identity provider
    WIF->>WIF: verify JWT signature and attribute-condition mapping
    WIF-->>GH: exchange for short-lived federated GCP token
    GH->>SA: impersonate service account using federated token
    SA-->>GH: short-lived GCP access token
    GH->>API: call gcloud / docker push using that access token
    Note over GH,API: no long-lived service account key ever stored in GitHub secrets

Why does the workflow need permissions: id-token: write at all — what does that token actually get used for?