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:
- 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.
- 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. - 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
| Stage | Host | Build | Wiring | Gate to enter |
|---|---|---|---|---|
| demo | demo.upsquad.ai — Cloudflare Pages, static | mock-only SPA, no API | none (mock data) | none — design source / NDA showcase |
| beta-dev | the dev box (Docker Compose stack, ADR-0001) | dev build | live-wired to the local core stack; a standing auth bypass is enabled here per RR-001 | push to a dev branch |
| beta | GCP spoke upsquad-beta | release build, promoted by digest | live-wired to the beta core stack; real Clerk auth | GitHub Environment beta human approval |
| app | GCP spoke upsquad-prod — ON HOLD | byte-identical promotion of the beta digest | live-wired to the prod core stack; real Clerk auth | GitHub 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 inupsquad-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) andspa-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-betatoday,upsquad-prodparked. 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
gcloudoutside an emergency runbook. The Pulumi state lives in the hub bucket; stacks arehub,beta, andprod. - 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
-
GitHub Environments
betaandproductioneach declare required reviewers — the two humans,upsqdvbandashik-upsqd-x. A deploy job thatenvironment: beta(orproduction) 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. -
When (and only when) a reviewer approves, GitHub mints an OIDC token for that job carrying an
environmentclaim (beta/production), alongside therepositoryandrefclaims it always carries. -
The hub's WIF pool attribute conditions require all of:
repository == "upsquad-ai/upsquad-core"ref == "refs/heads/main"- the
environmentclaim 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 toenvironment == 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, nopulumi upagainst 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) canPOSTa "deployment" and mark itsuccessfor 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:betaor:latestis 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+refclaims, 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-coreandupsquad-clientclaim**/workflows/**,scripts/**,infra/**(Pulumi), and the Compose/stack files → owned by the two humans, withrequire_code_owner_reviewsonmain. 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_TOKENand 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)
- Bot apps still hold
administrationwrite until trimmed (TOP RISK). Several agent GitHub Apps were installed with broad scopes includingadministration, 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. - 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.
- 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:
| Lever | Beta setting | Tripwire to revisit |
|---|---|---|
| CloudSQL | HA 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-instances | low (warm enough to hide cold starts for the demo path) | raise when p95 latency from cold starts breaches the interactive budget |
| GKE / ArgoCD | not used — Cloud Run only | revisit GKE + ArgoCD when we need >N long-lived services, complex rollout topologies, or the agent worker fleet outgrows Cloud Run's model |
| CDN fronting | CDN in front of the SPA GCS origin and the LB | re-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
| Phase | Estimate | Notes |
|---|---|---|
| Beta only (today) | ~$95–110 / mo | CloudSQL HA dominates; Cloud Run min-instances; CDN/egress modest at MVP traffic |
| After prod activation | ~$180–200 / mo | second spoke (CloudSQL HA + Cloud Run + LB/CDN) roughly doubles the runtime line |
| Ceiling | < $300 / mo | a 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:
- Founder-approved PR flips the prod stack's
provisioned: true. The prod Pulumi stack exists from day one withprovisioned: false; the flip is a CODEOWNER-reviewed one-line config change that signals "beta is mature." pulumi up prodstands up theupsquad-prodspoke (Cloud Run, CloudSQL HA, Redis, LB/CDN, GCS origin) from the already-written, inline-documented stack.- Gated
promote-prodruns: itenvironment: production, pauses for human approval (upsqdvb/ashik-upsqd-x), and on approval receives the WIF credential bearing theproductionenvironment claim. It promotes the same digest and the samereleases/<sha>/bundle beta is serving — byte-identical, no rebuild. - Cutover — re-point
app.upsquad.aiDNS at the prod LB and park the legacy Next portal atlegacy.app.upsquad.aiper 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
upsqdvbandashik-upsqd-xcan 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: writecan repoint a tag after review; the promotion unit must be an immutable digest. - Promote on GitHub Deployment records. Rejected —
deployments: writecan forge asuccessrecord 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:
- Per-env Artifact Registry replaces the hub AR (see the Hub-and-spoke section's reconciliation note) — registry IAM no longer spans both envs.
- Prod project ID is canonically
upsquad-prod(already used here; now the single canonical spelling —upsquad-prdis retired everywhere).
The build-once / promote-by-digest trust model is otherwise unchanged.