Skip to main content

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.yml builds and deploys — from every opened/synchronize/reopened event, to only PRs carrying the preview label — and extends destroy-preview.yml so 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 opened and again on every synchronize as 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.*.name is 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 a labeled event the just-added label is already reflected in this snapshot (GitHub applies the label before firing the webhook), so adding preview itself satisfies this clause in the same event that adds it.
  • Second clause — unchanged in shape, changed in effect. Before this change, labeled events never built (the clause was simply github.event.action != 'labeled'). Now a labeled event builds only when the label added was preview itself — 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); adding preview does.
  • deploy mirrors the same two clauses, plus needs.build.result == 'success'. The old code had github.event.action != 'labeled' && needs.build.result == 'success' — which would now be wrong: a labeled+preview event can make build succeed, and the old clause would still block deploy from ever running for it. Repeating build's full gate here is technically redundant with the needs.build.result == 'success' check (a build whose if was false reports skipped, not success), but it keeps this job correct independent of that invariant — the same defensive-repetition style the file already used.
  • companions gains one more required clause: the PR must currently carry preview. Companions target this PR's own backend preview — the companions job's "Check the backend preview answers" step curls https://pr-<N>.preview.dev-stables.xyz/health, not a shared environment — so no backend means no companion, full stop. Without the extra clause, a preview-web/preview-backoffice label added to a PR that never got preview would satisfy the job's existing (github.event.action == 'labeled' && needs.deploy.result == 'skipped') branch (deploy skips for lacking preview, 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.yml gains unlabeled as a second trigger, alongside the existing closed (and the workflow_call/workflow_dispatch paths 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-level if: does the real filtering, requiring either closed, or unlabeled with github.event.label.name == 'preview', or an event that is not pull_request at all (so the two workflow paths keep working unchanged).
  • A guard job re-reads live labels before destroy runs, mirroring D103's companion guard exactly. A fast remove-then-re-add of preview fires an unlabeled run here and a labeled run in deploy-preview.yml, with no ordering guarantee between the two workflows — landing the teardown after the redeploy would destroy the environment that redeploy just built. guard only runs on the unlabeled path (the closed/workflow_call/workflow_dispatch paths have nothing to race against) and re-reads /pulls/{n} (REST, not gh pr view, for the same scope-certainty reason D103 gives) with 3 retries. destroy's if: requires needs.guard.result != 'failure' and needs.guard.outputs.skip != 'true' — so a guard that ran and found preview back 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). Because destroy sits in a separate job rather than being threaded through the dozen-odd existing steps as a per-step if:, the whole job either runs or it does not — there was no need to touch any of destroy's existing AWS/Cloud Map/SSM/S3 steps.
  • Concurrency is unchanged in both files, and this was verified rather than assumed. deploy-preview.yml and destroy-preview.yml already share the concurrency group preview-pr-<N>; destroy-preview.yml was already cancel-in-progress: false, and deploy-preview.yml's cancel-in-progress: ${{ github.event.action != 'labeled' }} is already false for any labeled event (including the new preview-add case) and for unlabeled (unlabeled was never in deploy-preview.yml's types: and still is not). So a fast remove-then-re-add of preview can never let either workflow cancel the other — both runs simply queue in whichever order they arrive, and the guard job above resolves correctness regardless of that order. Nothing needed to change here; this is the reasoning that confirms it.

Consequences

  • preview now 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.yml still destroys unconditionally after 48h idle (or if the PR is closed/missing), with no knowledge of the preview label at all — it calls destroy-preview.yml via workflow_call, which this change's destroy gate always admits. The label is what makes a culled preview revivable: if the label is still on the PR, the next push satisfies build's gate and redeploys from scratch, with no further action needed. A long-running-preview label still exempts a PR from the idle sweep entirely, independent of preview.
  • 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-backoffice to a PR lacking preview is now a clean, documented no-op (see the companions clause above) rather than a red job — the failure mode a naive fix (relying on the health check alone) would have produced instead.
  • Removing preview is 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 preview first. docs/preview-access-guide.md states 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 preview label — 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.yml doesn't know or care about preview (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 the preview label 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 within deploy's steps, which only run when deploy itself ran — no dangling "preview ready" comment can be posted for a PR that never got one.

Alternatives rejected

  • A deploy-preview command/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/reopened only, deploying unconditionally on opened. 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: false on 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 of destroy's existing steps, mirroring destroy-companion.yml line-for-line. Rejected as needless surface area: destroy-companion.yml has exactly one step after its guard, so a per-step if: is proportionate there; destroy-preview.yml has a dozen. A separate guard job with destroy depending 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.

← Decision log index