Skip to content

Cloud Build

Four triggers, one service account, two output repos. The triggers fire on activity in the tomoda-labs/tomoda GitHub repo and push Docker images to Artifact Registry.

Code: infrastructure/gcp/cloudbuild.tf, infrastructure/gcp/cloudbuild_iam.tf. Pipeline definitions (cloudbuild-backend.yaml, cloudbuild-frontend.yaml) live in the tomoda repo, not here — see docs/infrastructure/ in that repo.

Trigger matrix

Trigger Fires on Target repo Approval
backend-push-trigger Push to main, paths backend/** or cloudbuild-backend.yaml tomoda-dev-repo Auto
frontend-push-trigger Push to main, paths frontend/** or cloudbuild-frontend.yaml tomoda-dev-repo Auto
backend-release-trigger Tag matching ^v[0-9]+\.[0-9]+\.[0-9]+$ tomoda-prod-repo Auto (tag)
frontend-release-trigger Tag matching ^v[0-9]+\.[0-9]+\.[0-9]+$ tomoda-prod-repo Auto (tag)

Backend + web release together on one vX.Y.Z tag — both triggers fire on it, so a single version ships the whole server-delivered app. The native mobile app versions on its own cadence (its marketing version in app.config.js, plus OTA), not this tag; see the app repo's docs/frontend/native/release.md. A merge to main auto-deploys backend + web to dev.

Push to main
auto
tomoda-dev-repo
Push tag v*
auto
tomoda-prod-repo
Two trigger styles: push → dev (automatic), tag → prod (the tag is the gate). One v* tag ships backend + web together; native versions separately.

Tag-based prod = single source of truth

The only way to get an image into tomoda-prod-repo is to push a vX.Y.Z tag to main (backend + web build together). There is no manual docker push, no per-environment branch, and no release/* branch — the tag itself is the release artifact.

Substitutions

Triggers pass environment-specific values into the YAML via substitutions. Both YAML files live in the tomoda repo; this Terraform code only sets the variables.

Common to all four triggers

Substitution Source
_REGION var.region (always us-central1)
_REPO_NAME dev triggers → tomoda-dev-repo; release triggers → tomoda-prod-repo

Dev push triggers add

Substitution Value
TAG_NAME latest (push triggers re-tag latest on every successful build)

Frontend triggers add

Substitution Dev value Prod value
_WS_URL wss://api-dev.tomoda.life/ws wss://api.tomoda.life/ws
_FRONTEND_API_URL https://api-dev.tomoda.life/api/v1 https://api.tomoda.life/api/v1
_WEB_URL https://app-dev.tomoda.life https://tomoda.life
_GOOGLE_WEB_CLIENT_ID dev web client prod web client
_GOOGLE_IOS_CLIENT_ID shared iOS client shared iOS client
_GOOGLE_ANDROID_CLIENT_ID shared Android client shared Android client

One mobile OAuth client per platform

iOS/Android reuse a single OAuth client across dev + prod — an iOS client is keyed on the bundle id (com.tomoda.app) and Android on package + signing SHA-1, so one client per platform serves both. Add the prod release signing SHA-1 to the Android client before shipping prod Android.

Service account & IAM

All four triggers use a single dedicated SA: cloudbuild-worker-sa@${project_id}.iam.gserviceaccount.com. The default Cloud Build SA (287267207777@cloudbuild.gserviceaccount.com) is granted roles/iam.serviceAccountUser on it so the platform can impersonate it when running the build.

Role What it enables
roles/logging.logWriter Builds can stream logs to Cloud Logging
roles/artifactregistry.writer Push image layers to both dev and prod repos
roles/container.developer Reserved for future post-build kubectl steps (not currently used)

roles/container.developer is unused: deployment runs via Argo CD Image Updater watching the registry, not a Cloud Build kubectl step.

Gating

Dev triggers deploy automatically: a merge to main builds and rolls out to the dev namespace, so dev always reflects main. The prod gate is the vX.Y.Z tag, which ships backend + web together. No Cloud Build trigger has a manual approval step.

Build outputs

Every successful build pushes a Docker image to the configured Artifact Registry repo. From there:

  • Dev: Argo CD Image Updater detects the new :latest digest and patches the dev Argo CD application's image tag.
  • Prod: same flow, but pinned to the semver tag the trigger built.

See Artifact Registry for the repo layout and Argo CD for the GitOps side.

Multi-arch images

The cluster runs ARM nodes (GKE), so every built image is a multi-arch manifest (linux/amd64 + linux/arm64) built with docker buildx:

  • App images — backend + frontend, via the app repo's cloudbuild-backend.yaml / cloudbuild-frontend.yaml.
  • Devops images — postgres, arc-runner, cost-exporter, via this repo's cloudbuild-postgres.yaml, cloudbuild-arc-runner.yaml, cloudbuild-cost-exporter.yaml.

The arm64 legs of CGO / from-source images (backend, postgres) build under QEMU emulation (tonistiigi/binfmt), slower than native amd64-only. The photon-indexer image is the exception — not multi-arch, so its index build runs out-of-cluster (see Photon).

Vulnerability scanning

Artifact Registry's automatic vulnerability scanning is off (containerscanning.googleapis.com not enabled): $0.26 per image scanned, ~$40/mo at dev's build frequency.

Scanning belongs in the build: add a trivy (or grype) step to cloudbuild-backend.yaml / cloudbuild-frontend.yaml in the tomoda monorepo that fails on HIGH/CRITICAL CVEs. Free, blocks a bad image before the registry, lives next to the code. Lands with the app-side CI work. Revisit AR's built-in scan only for continuous re-scanning of already-pushed images against newly-disclosed CVEs (which build-time trivy doesn't cover).

When triggers don't fire

Common reasons a push to main doesn't queue a build:

  1. No matching files changed. backend-push-trigger has included_files = ["backend/**", "cloudbuild-backend.yaml"]. A PR that only touches docs/ or frontend/ will not start a backend build.
  2. GitHub App permissions. The Cloud Build GitHub App must remain installed on the tomoda-labs/tomoda repo. Removing it silently disables every trigger.
  3. Wrong tag shape for prod. A prod build only fires for vX.Y.Z; any other tag is ignored.