Agentic AI on Google Cloud, Part 5: CI/CD with GitHub Actions and Terraform

Parts 1 to 4 built and hardened the assistant. This post makes shipping it routine: every change, to code or to infrastructure, travels through GitHub Actions and Terraform with tests, reviews and approvals, and nothing is ever deployed by typing commands from your laptop.

The series. Part 1: the GCP map and first agent. Part 2: knowledge preparation and ingestion. Part 3: verification and citations. Part 4: testing, security and monitoring. Part 5 (this post): CI/CD with GitHub Actions and Terraform, plus a downloadable project.

1. Why a pipeline, and what goes wrong without one

Picture the first month in production. Someone fixes a bug on Friday by running gcloud run deploy from their laptop. It works, and nobody writes it down. In week three a colleague applies a Terraform change from a different laptop with older code, and the Friday fix disappears. In week four, production behaves differently from staging and nobody can say why, because there is no record of who changed what, and no test ran before any of it.

A pipeline fixes this with one rule: Git is the source of truth, and only the pipeline is allowed to change the cloud. You change a file, open a pull request, and automatic checks run. A person reviews. After merging, the same pipeline applies the change, first to development, then, after a human approval, to production. Every step is logged, repeatable and reversible.

In Part 4 we sketched this with Cloud Build. Here we build the same ideas on GitHub Actions, which is a good fit when your code already lives on GitHub. Pick one tool per project; the structure below works with either.

Two pipelines, because there are two kinds of change

APPLICATION PIPELINE (deploy-app) 1 Pull requestunit + security 2 Mergeto main 3 Buildpush images 4 Canaryno traffic 5 Eval gategolden set 6 Promote10%, approve, 100% INFRASTRUCTURE PIPELINE (deploy-infra) 1 Pull requestfmt + plan 2 Mergeto main 3 Apply devautomatic 4 Approvea reviewer 5 Apply prodreviewed Both pipelines run in GitHub Actions and act on GCP through Workload Identity Federation (Figure 2).
Figure 1. The application pipeline ships new versions of the code. The infrastructure pipeline changes the cloud resources themselves. They are separate so that a code deploy never needs an infrastructure change, and vice versa.

The split matters. The infrastructure pipeline runs Terraform: it creates and updates service accounts, Cloud Run services, Pub/Sub topics, the BigQuery table and so on. The application pipeline only builds container images and tells Cloud Run to run them. To keep them from fighting, Terraform creates the Cloud Run services once and is then told to ignore the image and the traffic split, which belong to the application pipeline.

The vocabulary

TermMeaning
CI (continuous integration)Checks that run automatically on every pull request: tests, linting, a Terraform plan
CD (continuous delivery)The automatic path from a merged change to running software, with approvals where you want them
Infrastructure as codeCloud resources described in files and created by a tool, so they can be reviewed and rebuilt
Terraform stateTerraform's record of what it created. It lives in a Cloud Storage bucket so the whole team and the pipeline share one truth
Plan and applyPlan shows what would change without changing anything. Apply makes the change. Reading the plan is the review
GitHub environmentA named target (dev, prod) with its own variables, secrets and optional required reviewers
DriftWhen the real cloud no longer matches the code, because someone changed it by hand

2. How GitHub is allowed to act on GCP (no keys)

The pipeline needs permission to create and deploy things. The old way was to create a service account, download a JSON key and paste it into GitHub as a secret. That key never expires, can be copied, and if it leaks, whoever has it owns your project.

The modern way is Workload Identity Federation. GitHub gives each job a short-lived signed token saying which repository and branch it comes from. You tell GCP once, "trust tokens from this repository". At run time the job trades its token for short-lived access as a service account. There is nothing to leak and nothing to rotate.

GitHub Actions jobhas an OIDC token Workload IdentityFederation: pool + provider Service accounttf-sa or deploy-sa GCP resourcesCloud Run, state, images 1 presents a signed token 2 checks repo and branch 3 impersonates 4 short-lived access No JSON key is created or stored in GitHub. If the repository is wrong, or the branch is not allowed, step 2 refuses.
Figure 2. Keyless authentication. GCP verifies the token against the rule you set (this repository only), then lets the job act as a specific service account for a short time.

We create two such service accounts per environment, on purpose. tf-sa is powerful, because Terraform must create service accounts, topics and databases. deploy-sa is narrow: it can push images and update Cloud Run revisions, nothing more. Application deploys, which happen many times a day, therefore never hold the power to rewrite your infrastructure.

3. Hands-on: build the pipeline

Eight steps. First the one-time foundation (Steps 1 to 2), then the checks that run on every pull request (Steps 3 to 4), then the two delivery pipelines (Steps 5 to 6), and finally keeping them honest over time (Steps 7 to 8). Every file shown is in the downloadable project at the end of the post.

Step 1. Bootstrap each project once

The next job: prepare the ground that the pipeline itself needs. A pipeline cannot create the bucket that stores its own state, nor the trust relationship that lets it log in, so these few things are done once, by a person, with a script. Run it once for the development project and once for production. It creates the state bucket (with versioning, so a bad state file can be recovered), the Workload Identity pool and provider limited to your repository, the two service accounts, and their roles.

# 2. Workload Identity Federation: GitHub Actions proves who it is with a short-lived OIDC token
gcloud iam workload-identity-pools create github --project "$PROJECT" --location global \
  --display-name "GitHub Actions" || true
gcloud iam workload-identity-pools providers create-oidc github-provider --project "$PROJECT" \
  --location global --workload-identity-pool github \
  --issuer-uri "https://token.actions.githubusercontent.com" \
  --attribute-mapping "google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition "assertion.repository=='$GITHUB_REPO'" || true

The --attribute-condition line is the most important one: it makes GCP refuse tokens from any repository other than yours. The script ends by printing the values you need as GitHub variables.

Because tf-sa holds broad roles, protect it. Keep the condition on your repository, require reviews on the infra/ folder, and consider tightening it to the main branch (the token carries the branch in attribute.ref) once the pipeline works.

Step 2. Describe the platform in Terraform

The next job: turn every manual command from Parts 1 and 2 into a resource. The project keeps the resources in one reusable module (modules/platform) and gives each environment a thin folder (envs/dev, envs/prod) that calls it with its own values. Dev and prod therefore use identical code and differ only in settings such as deletion protection and whether the API is public.

infra/terraform/
├── bootstrap (script, not Terraform)  -> ../bootstrap/bootstrap.sh
├── modules/platform/   service accounts, Cloud Run services and job, Pub/Sub, BigQuery,
│                       Firestore, Artifact Registry, secrets, log metric
└── envs/{dev,prod}/    main.tf (calls the module) + terraform.tfvars (project id, model names)

Two details deserve attention. The environment leaves the state location empty (backend "gcs" {}), and the pipeline supplies the bucket and prefix at run time, which keeps the code identical across projects. And the Cloud Run service tells Terraform to leave the image alone after the first creation:

resource "google_cloud_run_v2_service" "api" {
  project  = var.project_id
  name     = "agent-api"
  location = var.region
  template {
    service_account = google_service_account.api.email
    scaling {
      min_instance_count = 0
      max_instance_count = var.max_instances
    }
    containers {
      image = var.api_image
      dynamic "env" {
        for_each = local.common_env
        content {
          name  = env.key
          value = env.value
        }
      }
      env {
        name  = "AGENT_MODEL_NAME"
        value = var.model_name
      }
      env {
        name  = "AGENT_VERIFIER_MODEL"
        value = var.verifier_model
      }
      env {
        name = "AGENT_CLAIMS_API_TOKEN"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.claims_token.secret_id
            version = "latest"
          }
        }
      }
    }
  }
  lifecycle {
    ignore_changes = [template[0].containers[0].image, template[0].revision, traffic, client, client_version]
  }
  depends_on = [google_secret_manager_secret_version.placeholder, google_project_iam_member.api]
}
  ...
  lifecycle {
    ignore_changes = [template[0].containers[0].image, template[0].revision, traffic, client, client_version]
  }
}

Without that lifecycle block, the next Terraform apply would roll production back to whatever image Terraform last knew, undoing the application pipeline. Try it locally first:

cd infra/terraform/envs/dev
terraform init -backend-config="bucket=YOUR-PROJECT-tfstate" -backend-config="prefix=agent/dev"
terraform fmt -recursive ../..
terraform validate
terraform plan

Resource arguments change between provider versions, so treat terraform validate and the provider documentation as the final word. Note also that the claims token is a placeholder secret version; add the real value as a new version outside Terraform, so the token never enters the state file.

Step 3. Check every pull request

The next job: make mistakes cheap. This workflow runs on every pull request, in three parts: Python tests and linting (seconds, no cloud access), static Terraform checks for both environments (formatting and validation, no cloud access), and finally a plan against dev, posted back to the pull request as a comment. The reviewer reads the plan the way they would read a code diff: "this change will create one topic and modify one service account".

name: ci
on:
  pull_request:

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: ruff check src tests
      - run: pytest tests/unit -q

  terraform-static:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        env: [dev, prod]
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive infra/terraform
      - run: terraform -chdir=infra/terraform/envs/${{ matrix.env }} init -backend=false
      - run: terraform -chdir=infra/terraform/envs/${{ matrix.env }} validate

  plan-dev:
    needs: [test, terraform-static]
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.TF_SA }}
      - uses: hashicorp/setup-terraform@v3
      - name: Plan
        run: |
          cd infra/terraform/envs/dev
          terraform init -input=false -backend-config="bucket=${{ vars.TFSTATE_BUCKET }}" -backend-config="prefix=agent/dev"
          terraform plan -input=false -no-color > "$GITHUB_WORKSPACE/plan.txt"
      - name: Comment the plan on the pull request
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('plan.txt', 'utf8').slice(0, 60000);
            await github.rest.issues.createComment({
              issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo,
              body: "Terraform plan for dev:\n```\n" + plan + "\n```"
            });

The permissions block lists id-token: write; without it the job cannot request the token that Workload Identity Federation needs. The plan job runs in the dev environment, which is how it receives that environment's variables.

Step 4. Set up the GitHub environments and protections

The next job: decide who may approve what. Environments are how GitHub turns "this needs a human" into a rule that cannot be skipped.

EnvironmentReviewersUsed by
devnone: automaticPR plan, infra apply to dev, app deploy to dev
prodrequiredinfra apply to prod, canary deploy to prod
prod-promoterequiredmoving production from 10% to 100%

For dev and prod set the variables printed by the bootstrap script (PROJECT_ID, REGION, TFSTATE_BUCKET, WIF_PROVIDER, TF_SA, DEPLOY_SA) and a secret EVAL_TOKENS holding test-customer tokens for the eval gate. Then protect the main branch: require the ci checks to pass, require a review, and add a CODEOWNERS file so changes under infra/ need the platform team.

Step 5. Deliver infrastructure with Terraform

The next job: apply merged infrastructure changes safely. When a change under infra/ is merged, the pipeline applies it to dev automatically and then pauses at the prod environment until a reviewer approves. The concurrency setting stops two applies from running at once, which would fight over the state lock.

jobs:
  dev:
    runs-on: ubuntu-latest
    environment: dev
    concurrency: terraform-dev
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.TF_SA }}
      - uses: hashicorp/setup-terraform@v3
      - run: |
          cd infra/terraform/envs/dev
          terraform init -input=false -backend-config="bucket=${{ vars.TFSTATE_BUCKET }}" -backend-config="prefix=agent/dev"
          terraform plan -input=false -out=tfplan
          terraform apply -input=false tfplan

The same job is repeated for prod with prefix=agent/prod, after the approval. For stricter reviews, save the plan as an artifact in one job and apply exactly that file in the next, so what was approved is what runs.

Step 6. Deliver the application: canary, evaluate, promote

The next job: ship code the way Part 4 described, but automatically. One reusable workflow does the work for any environment: it builds both images tagged with the commit, deploys the API as a new Cloud Run revision with no traffic and the tag canary, updates the ingestion service and batch job, runs the golden-set eval against the canary's own URL, and only then shifts traffic.

# Reusable workflow: build both images, deploy a canary revision, run the eval gate, shift traffic.
name: _deploy
on:
  workflow_call:
    inputs:
      environment:
        type: string
        required: true
      canary_percent:
        type: number
        default: 10

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    env:
      REG: ${{ vars.REGION }}-docker.pkg.dev/${{ vars.PROJECT_ID }}/agent
    steps:
      - uses: actions/checkout@v4
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.DEPLOY_SA }}
      - uses: google-github-actions/setup-gcloud@v2
      - run: gcloud auth configure-docker ${{ vars.REGION }}-docker.pkg.dev --quiet

      - name: Build and push images (tagged with the commit)
        run: |
          docker build -f Dockerfile        -t $REG/api:${{ github.sha }}    .
          docker build -f Dockerfile.ingest -t $REG/ingest:${{ github.sha }} .
          docker push $REG/api:${{ github.sha }}
          docker push $REG/ingest:${{ github.sha }}

      - name: Deploy API as a canary revision (no traffic)
        run: |
          gcloud run deploy agent-api --image $REG/api:${{ github.sha }} --region ${{ vars.REGION }} \
            --no-traffic --tag canary --quiet

      - name: Update ingestion service and batch job
        run: |
          gcloud run deploy rag-ingestion --image $REG/ingest:${{ github.sha }} --region ${{ vars.REGION }} --quiet
          gcloud run jobs update rag-batch-ingestion --image $REG/ingest:${{ github.sha }} --region ${{ vars.REGION }} --quiet

      - name: Eval gate against the canary URL
        env:
          EVAL_TOKENS: ${{ secrets.EVAL_TOKENS }}
        run: |
          URL=$(gcloud run services describe agent-api --region ${{ vars.REGION }} --format=json \
                | jq -r '.status.traffic[] | select(.tag=="canary") | .url')
          export GCP_ID_TOKEN=$(gcloud auth print-identity-token --audiences="$URL" 2>/dev/null || true)
          echo "$EVAL_TOKENS" > evals/test_tokens.json
          pip install -q httpx pyyaml
          python evals/run_eval.py "$URL"

      - name: Shift traffic to the canary
        run: |
          gcloud run services update-traffic agent-api --region ${{ vars.REGION }} \
            --to-tags canary=${{ inputs.canary_percent }}

The caller decides how far to go. Dev takes 100% straight away; production takes 10% and then waits for a second approval before the full switch:

  dev:
    needs: test
    uses: ./.github/workflows/_deploy.yml
    with:
      environment: dev
      canary_percent: 100
    secrets: inherit

  prod:
    needs: dev
    uses: ./.github/workflows/_deploy.yml   # the "prod" environment requires a reviewer to approve
    with:
      environment: prod
      canary_percent: 10
    secrets: inherit

  promote-prod:
    needs: prod
    runs-on: ubuntu-latest
    environment: prod-promote               # second approval, after watching the 10% canary
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.WIF_PROVIDER }}
          service_account: ${{ vars.DEPLOY_SA }}
      - uses: google-github-actions/setup-gcloud@v2
      - run: gcloud run services update-traffic agent-api --region ${{ vars.REGION }} --to-latest

Read the approvals as two different questions. The prod approval asks "should this version meet real customers at all?" and the prod-promote approval asks "after watching 10% of traffic (Part 4's dashboards and alerts), should everyone get it?". If the answer to the second is no, roll back, which is one command and does not rebuild anything:

gcloud run services update-traffic agent-api --region $REGION --to-revisions PREVIOUS_REVISION=100
The eval step passes the Google identity token in X-Serverless-Authorization and the customer token in Authorization. A private Cloud Run service reads the first for IAM and your app reads the second for the customer, so one header cannot carry both. The images are rebuilt in each environment's own registry from the same commit; if you need byte-identical artifacts across environments, use a shared registry and grant both runtime identities read access.

Step 7. Watch for drift

The next job: catch the hand edit. Someone will eventually change a setting in the console during an incident. A weekly job runs terraform plan against each environment and fails if the plan is not empty, which turns silent drift into a visible failed run.

      - name: Detect drift
        run: |
          cd infra/terraform/envs/${{ matrix.env }}
          terraform init -input=false -backend-config="bucket=${{ vars.TFSTATE_BUCKET }}" -backend-config="prefix=agent/${{ matrix.env }}"
          code=0
          terraform plan -input=false -detailed-exitcode -no-color || code=$?
          if [ "$code" -eq 2 ]; then echo "::error::Drift detected in ${{ matrix.env }}"; exit 1; fi
          exit "$code"

The key is -detailed-exitcode: exit code 0 means no changes, 2 means differences exist, 1 means an error. When drift is found, decide whether the code or the console is right, then update one to match the other.

Step 8. Everyday operations

The next job: know the routine moves, so none of them require improvising.

SituationWhat to do
Ship a code changePull request, review, merge. The application pipeline does the rest
Change infrastructureEdit Terraform, read the plan comment, merge, approve the prod apply
Bad release in productionMove traffic back to the previous revision; fix forward through a normal pull request
Urgent fixSame pipeline, same gates; use workflow_dispatch to start it manually. Do not bypass the checks
Rotate the claims tokenAdd a new Secret Manager version; new instances pick up latest
Change model name or limitsEdit terraform.tfvars in a pull request; it follows the infrastructure pipeline
Add an environmentNew project, run the bootstrap script, copy an envs/ folder, add a GitHub environment

4. Pitfalls we hit on paper

SymptomLikely cause
Authentication step fails with a permission errorMissing id-token: write, a wrong provider name, or the attribute condition does not match your repository name (case matters)
Deploy fails naming a service accountdeploy-sa lacks roles/iam.serviceAccountUser on api-sa or ingest-sa. Grant it after the first Terraform apply
Terraform apply rolls production backThe ignore_changes block for image and traffic is missing
"Error acquiring the state lock"Two applies overlapped, or a run was cancelled. Use concurrency; unlock only when sure nothing is running
First deploy fails reading the secretThe secret has no version. The placeholder version in the module avoids this
Eval gate returns 401 or 403The identity token or test tokens are missing, or the audience does not match the canary URL
Plan comment is cut offComments have a size limit; the workflow truncates it. Open the run log for the full plan

5. Download the project

Everything in the series is assembled into one repository you can unzip, read and adapt: insurance-agent.zip. It contains the application code (API, agent, tools, verification, retrieval, ingestion), tests and evaluation files, Dockerfiles, the Terraform platform module with dev and prod environments, the bootstrap script and the four GitHub workflows from this post. Its README lists the layout and a local quick start.

insurance-agent/
├── src/{api,agent,verify,rag,ingestion,common}
├── tests/{unit,security} + fake_claims.py
├── evals/                      golden set, runner, thresholds
├── infra/bootstrap/            one-time project setup
├── infra/terraform/            modules/platform + envs/{dev,prod}
├── .github/workflows/          ci, _deploy, deploy-app, deploy-infra, drift
└── Dockerfile, Dockerfile.ingest, requirements*.txt, Makefile, README.md
What has and has not been checked. The Python files compile, and the eight unit tests in tests/unit pass in a plain Python environment. The workflows parse as valid YAML. Nothing has been run against real GCP or GitHub, and the Terraform has not been through terraform validate. Treat the project as a carefully assembled scaffold: validate it in a sandbox, and confirm ADK, Gen AI SDK, provider and workflow-action details against current documentation.

6. Checklist and where to go from here

AreaReady when
AuthenticationWorkload Identity Federation with a repository condition; no JSON keys anywhere
SeparationTerraform identity and deploy identity are different; image and traffic are owned by the app pipeline
ReviewsPlan posted on every pull request; protected main branch; CODEOWNERS on infra
ApprovalsProduction apply, canary and promotion each need a named reviewer
Safety netsEval gate blocks bad canaries; rollback rehearsed; state bucket versioned
HygieneWeekly drift check; provider and action versions pinned and reviewed

This closes the series: an architecture (Part 1), knowledge and ingestion (Part 2), verified answers (Part 3), tested and monitored operation (Part 4) and an automated, reviewable delivery process (Part 5). Natural next steps are write operations with approval workflows on Cloud Workflows, the knowledge graph, a React interface with clickable citations, and a nightly reindex scheduled with Cloud Scheduler.

References: google-github-actions/auth, Workload Identity Federation, Terraform GCS backend, GitHub environments, Cloud Run traffic management.

Comments

Popular posts from this blog

Automate Blog Content Creation with n8n and Grok 3 API

LangGraph Tutorial: Understanding Concepts, Functionalities, and Project Implementation

Kaggle Tutorial · Data Science in Retail