Skip to main content

ADR-0017 — Staged Promotion Pipeline (human-gated, build-once, WIF-anchored)

  • Status: Accepted (founder approved 2026-06-12)
  • Date: 2026-06-12
  • Deciders: Vaisakh, Ashik
  • Related: ADR-0018 (Class A cloud foundation — the platform foundation this delivery path runs on; reconciled with this ADR 2026-06-12), ADR-0016 (demo-fork frontend strategy — amended same day), ADR-0001 (hybrid local dev stack), ADR-0002 (dual business model — cloud + on-prem), ADR-0015 (A2A communication model), the MVP plan "Giving Life to the Demo" (2026-06-10), client #314 (demo→app design-sync CI gate)

Context

We now have a real product to ship (app/, the demo fork — see ADR-0016) and need a path from a developer's box to a tenant-facing host that is safe, reproducible, and cheap. The MVP is being built with the platform itself (dogfooding), so the deployment pipeline is also the first piece of our own governance story: a human must stand behind every promotion, and every promotion must be provable after the fact.

Three constraints shape the decision:

  1. Trust must not rest on anything forgeable. Seven agent GitHub Apps hold write scopes across the repos. A pipeline whose security depends on "the right commit was deployed" or "the right tag was pushed" is only as strong as the weakest token that can rewrite that record. We need a gate that GCP itself enforces, not one the shipping automation merely asserts.
  2. No rebuild between environments. A binary tested in beta must be the same bytes that reach app. Re-running a build for prod re-opens the supply chain at the worst possible moment.
  3. Lean now, scale on tripwires. Beta is the only live GCP footprint until the founder declares it mature. Prod is architected from day one but parked behind a placeholder gate so activation is a config flip, not a redesign.

Decision

A four-stage promotion pipeline. Each forward hop is human-gated, and the deploy credential is issued by GCP only to a workflow that has already passed a GitHub Environment human-approval gate, running on main, in upsquad-ai/upsquad-core. Artifacts are built once and promoted by digest — never rebuilt.

The four stages

StageHostBuildWiringGate to enter
demodemo.upsquad.ai — Cloudflare Pages, staticmock-only SPA, no APInone (mock data)none — design source / NDA showcase
beta-devthe dev box (Docker Compose stack, ADR-0001)dev buildlive-wired to the local core stack; a standing auth bypass is enabled here per RR-001push to a dev branch
betaGCP spoke upsquad-betarelease build, promoted by digestlive-wired to the beta core stack; real Clerk authGitHub Environment beta human approval
appGCP spoke upsquad-prodON HOLDbyte-identical promotion of the beta digestlive-wired to the prod core stack; real Clerk authGitHub Environment production human approval — placeholder gate exists from day one, activation is founder-declared

demo is the only mockup stage and the only Cloudflare Pages host; everything downstream is a live-wired application (see ADR-0016 amendment). beta-dev is not a hosted environment — it is the dev box, named here so the promotion chain reads end-to-end. app is fully designed and provisioned-in-code but not stood up until the founder declares beta mature; its gates, environment, and Pulumi stack all exist so that activation is a reviewed config change rather than new work.

Hub-and-spoke GCP layout

Reconciled 2026-06-12 (see ADR-0018). The container Artifact Registry moved from the hub to a per-env AR inside each spoke project — beta AR in upsquad-beta, prod AR in upsquad-prod, with gated digest-copy promotion between them. This satisfies ADR-0018's registry-IAM isolation requirement (no single registry whose IAM spans both envs). The hub keeps the WIF pool, pulumi-state, spa-releases, and the audit/billing sinks. The "build-once / promote-by-digest" trust model below is unchanged — only the registry's location moved (hub → per-env).

  • Hub — GCP project upsquad-core. Holds the shared, cross-environment infrastructure: the Workload Identity Federation (WIF) pool that brokers GitHub → GCP credentials, and two GCS buckets — pulumi-state (Pulumi backend) and spa-releases (immutable per-SHA SPA bundles, see "build-once" below) — plus the audit/billing sinks. The container Artifact Registry is no longer in the hub (see the reconciliation note above); the hub holds no env container images.
  • Spokes — one GCP project per live environment: upsquad-beta today, upsquad-prod parked. Each spoke holds its own runtime — Cloud Run (the core API), CloudSQL, Redis, a load balancer + CDN, the GCS origin for that env's SPA assets — and its own Artifact Registry (asia-south1-docker.pkg.dev/upsquad-{beta,prod}/upsquad). CI builds + pushes to the beta AR; the gated promotion copies the image by digest into the prod AR. Spokes pull images from their own AR only; they never build.
  • Provisioning is Pulumi-only (TypeScript), and inline documentation is a hard requirement — every resource block carries a comment explaining why it exists and what it costs. No resource is created by hand in the console or by gcloud outside an emergency runbook. The Pulumi state lives in the hub bucket; stacks are hub, beta, and prod.
  • Bootstrap floor — the one thing Pulumi cannot create for itself is the set of GCP projects and their billing linkage. The founder creates the projects and attaches billing; from there everything is code. This floor is deliberate: project creation and billing are the org-IAM boundary that stays in human hands (see Residual Risks).

The trust model (the heart of this ADR)

The pipeline's security does not come from the shipping automation behaving correctly. It comes from GCP refusing to issue a deployment credential to any workflow that did not pass a human gate, enforced by Workload Identity Federation attribute conditions that key off claims the automation cannot forge.

How the gate is wired

  1. GitHub Environments beta and production each declare required reviewers — the two humans, upsqdvb and ashik-upsqd-x. A deploy job that environment: beta (or production) pauses until one of them approves in the GitHub UI. Admin bypass is turned off on both environments — not even a repo admin can skip the wait.

  2. When (and only when) a reviewer approves, GitHub mints an OIDC token for that job carrying an environment claim (beta / production), alongside the repository and ref claims it always carries.

  3. The hub's WIF pool attribute conditions require all of:

    • repository == "upsquad-ai/upsquad-core"
    • ref == "refs/heads/main"
    • the environment claim is present and matches the target spoke's service account.

    The beta spoke's deploy service account is bound to a principal set scoped to environment == beta; the prod spoke's to environment == production. GCP hands back deploy credentials only to a token bearing the matching environment claim — which only exists because a human approved the gate. There is no credential, no pulumi up against a spoke, and no Cloud Run revision without that approval.

The net effect: the human approval is not advisory metadata sitting beside the deploy — it is the cryptographic precondition for the deploy credential existing at all.

Why deployment records and mutable tags are explicitly NOT trusted

We considered keying promotion on cheaper signals and rejected each because they are forgeable by tokens we already hand out:

  • GitHub Deployment records / deployment statuses are REST-forgeable. Any token with deployments: write (several of our bot apps have it) can POST a "deployment" and mark it success for any environment, ref, or SHA — no build, no gate, no artifact. Trusting a deployment record means trusting every holder of that scope to never lie. So a deployment record is treated as a convenience breadcrumb for humans, never as authorization.
  • Mutable GHCR / Artifact Registry tags are movable by packages: write. A tag like :beta or :latest is a pointer; anyone with package-write can repoint it at a different (e.g. un-reviewed) image after the gate passed. So tags are never the promotion unit — they are human-readable aliases only.

What we DO anchor provenance on

  • The OIDC token's environment + repository + ref claims, validated by WIF — these are minted by GitHub's OIDC provider and cannot be set by repo-scoped tokens. This is the root of trust.
  • The GitHub Environments approvals API / approval log — the record that a named human approved a named environment for a named run. It is written by the human-gate mechanism, not by a deploy token, and is the audit anchor for "who let this through."
  • Run artifacts of the build job (the digest manifest + the SPA bundle) — produced inside the gated run, addressed by content, consumed by digest.

Build-once, byte-identical beta → prod

  • The container image is built once in a gated run and pushed to the beta Artifact Registry (per-env AR, see the reconciliation note above), then referenced everywhere by immutable digest (@sha256:…), never by tag. Promoting beta→prod copies that digest into the prod AR and re-points the prod Cloud Run service at the same digest — no rebuild.
  • The SPA bundle is built once per source SHA and written to spa-releases/<sha>/ (immutable prefix). Promotion publishes a spoke's "live release" pointer at an already-uploaded <sha> prefix; the CDN serves the assets for that prefix. The prod bundle is the same bytes beta served.
  • A promotion therefore carries a digest manifest + a releases/<sha>/ reference, not a build instruction. There is no code path that compiles for prod.

Defense-in-depth (around the gate, not instead of it)

  • CODEOWNERS in both upsquad-core and upsquad-client claim **/workflows/**, scripts/**, infra/** (Pulumi), and the Compose/stack files → owned by the two humans, with require_code_owner_reviews on main. Changing the pipeline, the WIF wiring, or the infra requires a human code-owner review on top of the normal branch protection. The gate cannot be quietly rewritten by a bot PR.
  • Org-level default workflow permissions set to read-only. Workflows start with the minimum GITHUB_TOKEN and must opt into any write scope explicitly and visibly in the job definition — which is itself CODEOWNER-gated.
  • These controls protect the definition of the gate; the WIF environment-claim condition protects the execution. Subverting a deploy requires defeating both.

Residual risks (ranked)

  1. Bot apps still hold administration write until trimmed (TOP RISK). Several agent GitHub Apps were installed with broad scopes including administration, which can alter branch protection and environment settings. Until these are trimmed to least privilege, a compromised bot token could in principle weaken the gate it cannot otherwise bypass. Mitigation in flight: scope-trim pass on all seven apps; track to closure before prod activation. This is the single most important follow-up.
  2. Box-local tampering is now confined to dev / beta-dev. Anyone with the dev box can alter the local stack and the standing bypass (RR-001) lives there. That blast radius no longer reaches beta or prod, because those require the WIF human gate that the box cannot satisfy. Acceptable for dev; never extend the bypass past beta-dev.
  3. GCP org IAM is founder-only. Project creation, billing, and org-level IAM bindings are not delegated to any bot or automation. This is the bootstrap floor and stays manual by design.

RR-001 — standing dev-stack auth bypass

beta-dev runs with a standing authentication bypass so developers can exercise live-wired flows without Clerk. It is scoped to the dev box only, dead-code-eliminated from any release build (the VITE_DEV_MODE discipline from ADR-0016), and must never be reachable from beta or app. Recorded here as an accepted, contained residual risk.

Lean sizing + tripwires

Beta is intentionally minimal; each lever has a documented tripwire for when to spend more:

LeverBeta settingTripwire to revisit
CloudSQLHA enabled (we do not run a tenant DB without failover, even in beta)already at HA; scale tier up on sustained CPU / connection pressure
Cloud Run min-instanceslow (warm enough to hide cold starts for the demo path)raise when p95 latency from cold starts breaches the interactive budget
GKE / ArgoCDnot used — Cloud Run onlyrevisit GKE + ArgoCD when we need >N long-lived services, complex rollout topologies, or the agent worker fleet outgrows Cloud Run's model
CDN frontingCDN in front of the SPA GCS origin and the LBre-front / add edge regions when egress or TTFB by geography crosses target

The GKE/ArgoCD deferral matches ADR-0016's "static + CDN-first" serving and ADR-0015's queue-mediated agent model — neither needs a cluster yet.

Cost

PhaseEstimateNotes
Beta only (today)~$95–110 / moCloudSQL HA dominates; Cloud Run min-instances; CDN/egress modest at MVP traffic
After prod activation~$180–200 / mosecond spoke (CloudSQL HA + Cloud Run + LB/CDN) roughly doubles the runtime line
Ceiling< $300 / moa breach is itself a tripwire — investigate before approving spend above this

These are deliberately lean; the founder may approve managed upgrades against the tripwires above when traffic justifies them.

Prod activation runbook

app (upsquad-prod) is parked. Activation is a deliberate, reviewed sequence — not a surprise of normal merges:

  1. Founder-approved PR flips the prod stack's provisioned: true. The prod Pulumi stack exists from day one with provisioned: false; the flip is a CODEOWNER-reviewed one-line config change that signals "beta is mature."
  2. pulumi up prod stands up the upsquad-prod spoke (Cloud Run, CloudSQL HA, Redis, LB/CDN, GCS origin) from the already-written, inline-documented stack.
  3. Gated promote-prod runs: it environment: production, pauses for human approval (upsqdvb / ashik-upsqd-x), and on approval receives the WIF credential bearing the production environment claim. It promotes the same digest and the same releases/<sha>/ bundle beta is serving — byte-identical, no rebuild.
  4. Cutover — re-point app.upsquad.ai DNS at the prod LB and park the legacy Next portal at legacy.app.upsquad.ai per ADR-0016 (deferred-not-deleted). Rollback is a DNS re-point plus re-promoting the prior digest — within the <5-minute reversibility tenet.

Consequences

Positive

  • The human gate is enforced by GCP, not by trust in automation — the deploy credential literally does not exist without an approval claim.
  • Build-once / promote-by-digest closes the supply chain between environments; prod runs the exact bytes beta validated.
  • Lean and reversible — one live spoke today, prod a config flip away, every lever tripwired, cost under a hard ceiling.
  • Dogfoods our own governance — the pipeline is the first concrete "every action is human-approvable and provable after the fact" story.

Negative / trade-offs

  • Promotion is not instant — a human must approve each forward hop. This is the point, not a defect; the latency is acceptable for our release cadence.
  • Two-human bus factor on gates — only upsqdvb and ashik-upsqd-x can approve. Acceptable now; widen the reviewer set deliberately, never by weakening admin-bypass.
  • administration-scope trim is a hard prerequisite for treating the gate as airtight (Residual Risk 1) — tracked to closure before prod activation.

Alternatives considered

  • Promote on a mutable tag (:beta, :latest). Rejected — packages: write can repoint a tag after review; the promotion unit must be an immutable digest.
  • Promote on GitHub Deployment records. Rejected — deployments: write can forge a success record for any ref/SHA; a deployment record is a human breadcrumb, not authorization.
  • Long-lived GCP service-account keys in CI secrets. Rejected — a stealable static credential with no environment-claim binding defeats the entire trust model; WIF keyless federation is the whole point.
  • GKE + ArgoCD from day one. Rejected for now — Cloud Run covers the MVP service count with far less ops surface and cost; revisit on the documented tripwire.

Verification

Acceptance: a merge to main builds one image + one SPA bundle in a gated run; a beta deploy pauses until upsqdvb or ashik-upsqd-x approves the beta environment, and only then does WIF issue a credential and Cloud Run take the new revision. Demonstrate that a forged deployment record and a repointed tag both fail to produce a credential. Confirm app/upsquad-prod has a complete Pulumi stack at provisioned: false with a wired but dormant production gate. Confirm the prod promotion path re-uses the identical digest and releases/<sha>/ bundle with no rebuild step in the workflow graph.

Reconciliation (2026-06-12)

A second ADR was briefly merged sharing the "ADR-0017" number; the founder reconciled the two in-session on 2026-06-12. This ADR keeps the 0017 number (merged first); the other is renumbered to ADR-0018 — Activate Class A Cloud Infra for Beta + Prod. Division of governance: ADR-0017 (this) governs the app delivery path — stages, human-approval gates, and build-once / by-digest promotion. ADR-0018 governs the platform foundation — hierarchy, network, firewall, VPN-only access plane, registry isolation, and the data layer. Two reconciliation points touch this ADR:

  1. Per-env Artifact Registry replaces the hub AR (see the Hub-and-spoke section's reconciliation note) — registry IAM no longer spans both envs.
  2. Prod project ID is canonically upsquad-prod (already used here; now the single canonical spelling — upsquad-prd is retired everywhere).

The build-once / promote-by-digest trust model is otherwise unchanged.