D107 — Backend preview deploys are gated on a preview label, not on PR open (ACCEPTED)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. This decision changes when
deploy-preview.ymlbuilds and deploys — from everyopened/synchronize/reopenedevent, to only PRs carrying thepreviewlabel — and extendsdestroy-preview.ymlso removing the label tears the backend down without closing the PR. The workflow files (deploy-preview.yml,destroy-preview.yml) are the canonical source of truth; this ADR records the rationale.
Context
Every PR got a preview, whether anyone looked at it or not. deploy-preview.yml triggered on
pull_request: [opened, synchronize, reopened, labeled], and until this change build's only
gate was github.event.action != 'labeled' — i.e. it ran on every opened, every synchronize,
every reopened, for every PR, regardless of whether the PR needed a preview at all. Given the
fleet-wide cap of 6 concurrent preview environments (preview-access-guide.md
§Limits) and the ~8-minute build+deploy cost per push, this meant:
- Small PRs (a typo fix, a docs change that still counts as code, a one-line config tweak) paid
the same ~8-minute deploy on
openedand again on everysynchronizeas PRs that actually needed reviewer eyes on a running environment. - The 6-slot cap filled with previews nobody was looking at, triggering
preview-idle-cleanup.yml's eviction of the oldest idle preview — sometimes a preview someone was about to use — to make room for one that would sit idle for 48 hours and get reaped itself. - D103 already established the label as an
explicit, reviewer-visible desired-state toggle for companion frontend previews
(
preview-web/preview-backoffice: label present → companion exists). The backend preview itself had no equivalent toggle — it existed unconditionally the moment a PR opened, which is the inconsistency this decision resolves. D103's own text noted the assumption this decision narrows: "any push to the backend PR already rebuilt the companion" was true only because every push already deployed the backend unconditionally.
Decision — preview becomes the backend's desired-state toggle, exactly like D103's companion labels
deploy-preview.yml's build job now requires the PR to currently carry the preview label,
in addition to the existing labeled-event carve-out:
if: >-
contains(github.event.pull_request.labels.*.name, 'preview') &&
(github.event.action != 'labeled' || github.event.label.name == 'preview')
- First clause — the label must be present.
github.event.pull_request.labels.*.nameis a GitHub Actions object filter, yielding the array of label names on the PR at the time of this event;contains()checks array membership. On alabeledevent the just-added label is already reflected in this snapshot (GitHub applies the label before firing the webhook), so addingpreviewitself satisfies this clause in the same event that adds it. - Second clause — unchanged in shape, changed in effect. Before this change,
labeledevents never built (the clause was simplygithub.event.action != 'labeled'). Now alabeledevent builds only when the label added waspreviewitself — so adding a companion label (preview-web/preview-backoffice) to an already-preview-labeled PR still does not rebuild the backend (today's companion fast path, preserved); addingpreviewdoes. deploymirrors the same two clauses, plusneeds.build.result == 'success'. The old code hadgithub.event.action != 'labeled' && needs.build.result == 'success'— which would now be wrong: alabeled+previewevent can makebuildsucceed, and the old clause would still blockdeployfrom ever running for it. Repeatingbuild's full gate here is technically redundant with theneeds.build.result == 'success'check (abuildwhoseifwas false reportsskipped, notsuccess), but it keeps this job correct independent of that invariant — the same defensive-repetition style the file already used.companionsgains one more required clause: the PR must currently carrypreview. Companions target this PR's own backend preview — thecompanionsjob's "Check the backend preview answers" step curlshttps://pr-<N>.preview.dev-stables.xyz/health, not a shared environment — so no backend means no companion, full stop. Without the extra clause, apreview-web/preview-backofficelabel added to a PR that never gotpreviewwould satisfy the job's existing(github.event.action == 'labeled' && needs.deploy.result == 'skipped')branch (deploy skips for lackingpreview, which is indistinguishable from the pre-existing "skipped because this is a companion-label event on an already-deployed PR" case) and reach the health check with nothing to check — a guaranteed-red job instead of a clean skip.destroy-preview.ymlgainsunlabeledas a second trigger, alongside the existingclosed(and theworkflow_call/workflow_dispatchpaths used by idle cleanup and manual teardown).pull_request: types:cannot filter by which label was removed, so every label removal on every PR starts a run; a job-levelif:does the real filtering, requiring eitherclosed, orunlabeledwithgithub.event.label.name == 'preview', or an event that is notpull_requestat all (so the two workflow paths keep working unchanged).- A
guardjob re-reads live labels beforedestroyruns, mirroring D103's companion guard exactly. A fast remove-then-re-add ofpreviewfires anunlabeledrun here and alabeledrun indeploy-preview.yml, with no ordering guarantee between the two workflows — landing the teardown after the redeploy would destroy the environment that redeploy just built.guardonly runs on theunlabeledpath (theclosed/workflow_call/workflow_dispatch paths have nothing to race against) and re-reads/pulls/{n}(REST, notgh pr view, for the same scope-certainty reason D103 gives) with 3 retries.destroy'sif:requiresneeds.guard.result != 'failure'andneeds.guard.outputs.skip != 'true'— so a guard that ran and foundpreviewback stands down, and a guard that failed to determine the live state also stands down (fail-closed: a lost teardown leaves an extra live preview; a wrongly-run teardown destroys one that was just rebuilt). Becausedestroysits in a separate job rather than being threaded through the dozen-odd existing steps as a per-stepif:, the whole job either runs or it does not — there was no need to touch any ofdestroy's existing AWS/Cloud Map/SSM/S3 steps. - Concurrency is unchanged in both files, and this was verified rather than assumed.
deploy-preview.ymlanddestroy-preview.ymlalready share the concurrency grouppreview-pr-<N>;destroy-preview.ymlwas alreadycancel-in-progress: false, anddeploy-preview.yml'scancel-in-progress: ${{ github.event.action != 'labeled' }}is alreadyfalsefor anylabeledevent (including the newpreview-add case) and forunlabeled(unlabeledwas never indeploy-preview.yml'stypes:and still is not). So a fast remove-then-re-add ofpreviewcan never let either workflow cancel the other — both runs simply queue in whichever order they arrive, and theguardjob above resolves correctness regardless of that order. Nothing needed to change here; this is the reasoning that confirms it.
Consequences
previewnow means what it looks like — the same property D103 gave the companion labels. Label present → backend preview exists; label absent → it does not. A PR that does not need review no longer costs a deploy slot or an ~8-minute build on every push.- The 6-slot cap is now demand-driven. Previews exist for PRs someone is actively reviewing,
not for every open PR — reducing pressure on
preview-idle-cleanup.yml's eviction path and the chance it reaps a preview someone was about to use. - Idle cleanup's behavior is unchanged, and that is deliberate.
preview-idle-cleanup.ymlstill destroys unconditionally after 48h idle (or if the PR is closed/missing), with no knowledge of thepreviewlabel at all — it callsdestroy-preview.ymlviaworkflow_call, which this change'sdestroygate always admits. The label is what makes a culled preview revivable: if the label is still on the PR, the next push satisfiesbuild's gate and redeploys from scratch, with no further action needed. Along-running-previewlabel still exempts a PR from the idle sweep entirely, independent ofpreview. - A companion label is now a strict "is the backend up" gate, not just a "did I add the right
label" gate. Adding
preview-web/preview-backofficeto a PR lackingpreviewis now a clean, documented no-op (see thecompanionsclause above) rather than a red job — the failure mode a naive fix (relying on the health check alone) would have produced instead. - Removing
previewis now a second way to tear down the backend, without closing the PR — the mirror image of D103's companion toggle, and useful for the same reason: a reviewer or author can drop a preview they no longer need mid-review, freeing a slot, without abandoning the PR. - A reviewer must now take one extra action to see a preview. Before, every PR had one by the
time CI finished; now, someone has to add
previewfirst.docs/preview-access-guide.mdstates this up front as step 1 of "How to Access a Preview," and its lifecycle table and troubleshooting rows were updated to match. - Rollout has a cutover gap for PRs already open when this ships. They have a live preview
from the old unconditional path but carry no
previewlabel —build's new gate means their next push does not redeploy, so the running environment silently stops tracking the branch. Nothing destroys it early:preview-idle-cleanup.ymldoesn't know or care aboutpreview(see above), so the stale environment simply serves increasingly-old code until the ordinary 48h idle sweep reaps it (or the PR closes). There is no automatic backfill of the label onto already-open PRs. Action for anyone with a preview open at cutover: add thepreviewlabel once to keep receiving deploys — after that it behaves like any other PR.docs/preview-access-guide.md's "Backend preview seems stale" troubleshooting row now leads with checking for the label for exactly this reason. deploy-preview.yml's PR comment is unaffected, because it is only posted from withindeploy's steps, which only run whendeployitself ran — no dangling "preview ready" comment can be posted for a PR that never got one.
Alternatives rejected
- A
deploy-previewcommand/slash-comment instead of a label. Rejected for consistency: D103 already established the label as this pipeline's toggle idiom for companions, GitHub's label UI needs no bot/comment-parsing infrastructure, and triage-level accounts can already add labels without write access (the same capability D103 accepted for tearing a companion down applies symmetrically here). - Gating on
synchronize/reopenedonly, deploying unconditionally onopened. Would still waste a deploy on every PR that never gets reviewed with a live environment, which is the actual problem this decision fixes — a "first push is free" carve-out solves nothing. - Cancelling in-flight runs across the label add/remove race instead of a live-label guard.
Rejected for the same reason D103 rejected it for companions: a cancelled teardown or a
cancelled deploy both leak or destroy real resources depending on which loses the race, whereas
queueing (the current
cancel-in-progress: falseon both sides) plus a live re-read resolves it without a coin flip. - Threading a per-step
if: steps.guard.outputs.skip != 'true'through every one ofdestroy's existing steps, mirroringdestroy-companion.ymlline-for-line. Rejected as needless surface area:destroy-companion.ymlhas exactly one step after its guard, so a per-stepif:is proportionate there;destroy-preview.ymlhas a dozen. A separateguardjob withdestroydepending on its output achieves the identical semantics — skip the whole job, cleanly — without touching any of the AWS/Cloud Map/SSM/S3 steps that already exist.