Deploy What Changed: Nx affected + Cloud Run + Workload Identity Federation
DEV Community

Deploy What Changed: Nx affected + Cloud Run + Workload Identity Federation

A merge to main should ship the services that merge touched - and nothing else. Here is the whole pipeline: keyless auth, one deploy target per app, and the graph work that makes "affected" trustworthy. Stack: Nx monorepo ยท Python + Node ยท Cloud Run. Auth: Workload Identity Federation. Secrets in repo: zero. 00. The shape One command, two callers Most broken deploy workflows are broken the same way: the YAML contains a second, drifting copy of the deploy commands. Someone tunes memory limits locally, ships it by hand, and CI keeps deploying the old shape for a month before anyone notices. So the rule that makes everything else work: the deploy command lives in the repo, next to the app it deploys. CI does not know how to deploy anything. It knows how to ask. In an Nx workspace that means a deploy target per app: apps/web/project.json { "targets": { "deploy": { "executor": "nx:run-commands", "dependsOn": ["build"], "options": { "command": "docker build --platform linux/amd64 -f apps/web/Dockerfile -t gcr.io/$PROJECT/web:latest . && docker push gcr.io/$PROJECT/web:latest && gcloud run deploy web --image gcr.io/$PROJECT/web:latest --region us-central1 --allow-unauthenticated" } } } } Now a human types nx deploy web and CI runs nx affected -t deploy . Same command, same flags, same memory limits, no drift. The rest of this post is the two hard parts: proving to Google who GitHub is, and proving to Nx what actually changed. New project? Create the registry first. Container Registry stopped accepting writes on March 18, 2025 - gcr.io hostnames now proxy to Artifact Registry, but only for repositories that already exist. An established project usually has that mirror already; a brand-new one doesn't, anddocker push fails with a 404 the first time. Create it once:gcloud artifacts repositories create gcr.io --repository-format=docker --location=us --project=$PROJECT . New projects are better off skipping the mirror entirely and pushing tous-docker.pkg.dev/$PROJECT/gcr.io/web:latest instead. 01. Identity Stop putting service account keys in GitHub The tutorial answer is gcloud iam service-accounts keys create , paste the JSON into a repo secret, done. That key is a permanent credential with deploy rights, sitting in a place many people can read, that nothing rotates and nobody revokes. Workload Identity Federation replaces it. GitHub already signs a short-lived OpenID Connect (OIDC) token for every workflow run, describing the repo, the branch, the workflow. You teach Google to trust that issuer, then narrow the trust to exactly one repository. No key exists, so no key can leak. Before the gcloud commands, here's the whole exchange as a diagram - step through it to see where a token from the wrong repository actually gets rejected: Interactive diagram: the full GitHub to Google token exchange is a step-through diagram on the original post - see it here. In short: GitHub Actions requests an OIDC token, GitHub signs it, Google's Workload Identity Pool verifies the issuer and repository (rejecting any repo that does not match), then exchanges it for short-lived credentials scoped to the deploy service account, and those credentials deploy to Cloud Run. No long-lived key exists at any step. Create the deploy identity # The service account CI will impersonate. It has no key. gcloud iam service-accounts create github-deploy \ --display-name="GitHub Actions deploy" --project=$PROJECT SA=github-deploy@$PROJECT.iam.gserviceaccount.com # Deploy revisions, push images, and act as the runtime SA. for R in roles/run.admin \ roles/artifactregistry.writer \ roles/storage.admin \ roles/iam.serviceAccountUser \ roles/secretmanager.viewer; do gcloud projects add-iam-policy-binding $PROJECT \ --member="serviceAccount:$SA" --role=$R --condition=None done Why serviceAccountUser. Deploying a Cloud Run service that runs as another service account is an impersonation. Without roles/iam.serviceAccountUser the deploy fails at the very last step with a permission error that names the runtime account, not the deployer - an easy twenty minutes to lose. Trust GitHub, and only your repo gcloud iam workload-identity-pools create github \ --location=global --project=$PROJECT gcloud iam workload-identity-pools providers create-oidc github-provider \ --location=global --workload-identity-pool=github --project=$PROJECT \ --issuer-uri="https://token.actions.githubusercontent.com" \ --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \ --attribute-condition="assertion.repository=='my-org/my-repo'" The attribute condition is the security boundary. Omit --attribute-condition and you have told Google to trust every repository on GitHub. Anyone who can run a public workflow can then mint a token your pool accepts. Newergcloud versions refuse to create an unconditioned provider for exactly this reason. Keep the condition; make it as narrow as your workflow allows. Then let that repository - and nothing else in the pool - impersonate the deploy account: PN=$(gcloud projects describe $PROJECT --format='value(projectNumber)') gcloud iam service-accounts add-iam-policy-binding $SA --project=$PROJECT \ --role=roles/iam.workloadIdentityUser \ --member="principalSet://iam.googleapis.com/projects/$PN/locations/global/workloadIdentityPools/github/attribute.repository/my-org/my-repo" Three values go into repo secrets. None of them is a credential - they are addresses, and they are useless without a signed token from your repo. echo "projects/$PN/locations/global/workloadIdentityPools/github/providers/github-provider" \ | gh secret set WIF_PROVIDER echo "$SA" | gh secret set WIF_SERVICE_ACCOUNT echo "$PROJECT" | gh secret set GCP_PROJECT Tighten further before this touches production. The condition above is repo-only, which means it trusts any branch and any workflow in my-org/my-repo - not justdeploy.yml onmain . Two changes close that gap: - Restrict to the deploy ref. Add && assertion.ref=='refs/heads/main' to the condition. Now a workflow triggered from a feature branch - even one added by someone with ordinary write access - can't mint a usable token.- Bind to the repository ID, not its name. assertion.repository is a name, and names get reused: deletemy-org/my-repo and recreate it (or let the org rename), and a new, unrelated repository inherits the trust. Mapattribute.repository_id=assertion.repository_id andattribute.repository_owner_id=assertion.repository_owner_id in--attribute-mapping , then condition and bind on those IDs instead - they don't get reassigned when a name does. The roles nobody's tutorial lists The role list above already includes storage.admin at the project level - for a reason we only learned by getting it wrong first. Wiring this up for a real project, we started tighter: storage.admin scoped to just the Cloud Build staging bucket, on the theory that project-wide storage admin is a lot of blast radius for one deploy pipeline. Two failed deploys later, the reason became clear: gcloud builds submit doesn't only read and write objects in that bucket - before it uploads anything, it calls a project-scoped storage.buckets.list to confirm the bucket exists. No bucket-level binding, however permissive, can satisfy a call scoped to the whole project. The advice above is right; don't "improve" it. The second gap isn't in the role list at all. roles/viewer is required to stream build logs back to the CLI, separate from anything Cloud Build- or Storage-specific. Without it, gcloud builds submit exits non-zero and the whole nx deploy chain reports failure - even though the Cloud Build job itself finished and pushed the image successfully. We nearly chased a phantom second bug before running gcloud builds describe --format="value(status)" and seeing SUCCESS on a build the CLI, and therefore CI, had just reported as failed. roles/logging.viewer looks like the fix and isn't: the CLI's own error text checks for "Viewer/Owner of the project" - a primitive-role check, not a fine-grained permission. The lesson generalizes past this one pipeline: an error from a wrapper CLI ("forbidden from accessing bucket") describes where the failure surfaced, not why. When a permission error survives an IAM grant that should have fixed it, stop guessing at roles and get the client's raw HTTP trace instead: gcloud builds submit apps/web --tag $IMAGE --project $PROJECT --verbosity=debug It names the exact API call and scope that got denied - in less time than a second guess costs. 02. The workflow Ask Nx what to ship The whole file, then the four lines in it that matter. .github/workflows/deploy.yml name: Deploy on: push: branches: [main] workflow_dispatch: inputs: projects: description: 'Comma-separated projects (empty = affected only)' required: false default: '' permissions: contents: read actions: read id-token: write # mint the OIDC token - without this, auth fails # One deploy at a time. A second merge waits instead of racing Cloud Run. concurrency: group: deploy-main cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest env: # The deploy target expands $PROJECT (see apps/web/project.json above). # auth@v2 exports GCP_PROJECT/GOOGLE_CLOUD_PROJECT, not PROJECT - without # this line the image ref silently becomes gcr.io//web:latest. PROJECT: ${{ secrets.GCP_PROJECT }} steps: # Full history: affected diffs two commits. - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: pnpm/action-setup@v4 with: { version: 10 } - uses: actions/setup-node@v4 with: { node-version: 22, cache: pnpm } - run: pnpm install --frozen-lockfile - uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} - uses: google-github-actions/setup-gcloud@v2 - run: gcloud auth configure-docker gcr.io --quiet # Sets NX_BASE / NX_HEAD. Base = last commit this workflow # deployed successfully, so a red run is

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.