ADR-073: Preview environments per feature branch
Status: Accepted Date: 2026-04-23 Related: ADR-059 (project-first IA — multi-repo projects), ADR-067 (playbooks unified primitive), ADR-068 (playbook runtime architecture), ADR-069 (playbook security), ADR-070 (playbook UI contract), ADR-071 (product name — Weft), ADR-072 (infrastructure as playbooks), ADR-075 (Fleet-E2E cross-product branch-testing service — Phase D's Playwright source and its CI-plane counterpart)
Decision (one paragraph)
Every feature branch opened as a pull request on any repo inside a
project (ADR-059) spins up its own ephemeral, full-stack preview
environment at a private, Access-gated URL. The preview is composed
from production Dockerfiles, namespaced per-PR, gated by Cloudflare
Access behind the PR author and its reviewers, and torn down on PR
close, PR merge, or a 72-hour cron-enforced timeout — whichever comes
first. It is expressed as two Weft playbooks — release_flow: preview-up and release_flow:preview-down — composed from the
ADR-072 infra sub-playbook catalogue (cloudflare-dns-upsert,
cloudflare-tunnel-route-upsert, cloudflare-access-app-upsert, the
systemd/caddy pair). Cross-repo coordination is name-match by
branch: when spinning up a preview for PR feat/dark-mode on
buzzy-frontend, every other repo in the same project is checked
for a branch of the same name, and if present it is used instead of
main. Capacity is one Hetzner box with FIFO queueing and a four-
concurrent cap for Phase 1; overflow is old previews auto-torn-down
after 24 hours if a newer PR is queued. We ship in four phases
(manual invocation → webhook-driven → /ops/previews UI →
Playwright-against-preview in the AI review flow); Phases A and B are
committed scope, C and D are named horizons.
Context
Testing a feature that spans buzzy-frontend and buzzy-backend today — and increasingly, features that span three or four repos inside one project (ADR-059) — means one of two things, and neither is good:
- Merge and hope. The frontend PR and the backend PR each pass
their own CI and their own reviewers, each merges to
main, and the first time the two pieces run against each other in the same address space is on the production host five minutes later. If the API contract the frontend assumed is not quite the contract the backend shipped, the breakage is live and nobody saw it during review. The "is it tested in context" question is answered by the failure, not before it. - Run locally and hope it matches prod. The reviewer
git checkouts both branches, runsdocker compose up, and clicks around. Sometimes the reviewer has a different Node version. The reviewer's local database is not prod-shaped. The reviewer's browser is not sending Access headers. The operator whose review comment is "LGTM, looks fine locally" has answered a question the reviewer was not asked — how does it behave on the fleet — with evidence from a different system.
The common industry answer to this is review apps (or "preview environments", "PR environments", "ephemeral environments"): every open pull request gets a full-stack, publicly (or privately) addressable copy of the system running the PR's code; humans and AI reviewers exercise it end to end; teardown happens automatically on PR close. Heroku invented the pattern, Vercel scaled it to 10M developers, GitLab and Render and Netlify ship it as a checkbox.
Gyrum does not ship it today. The reasons are accidental, not principled: we did not have the primitives (ADR-067/068 landed this cycle), we did not have the infra composition (ADR-072 just merged), we did not have the multi-repo project model (ADR-059 landed a week ago), and we did not have the production Dockerfiles (the Dockerfile-per-repo work is in-flight this week). The accidental absence means every PR review in the last three months has been lower-evidence than it could have been. That is a compounding cost: review confidence is lower, regressions are caught later, and the "merge to main to find out" muscle builds habit that preview environments specifically exist to unlearn.
Three converging pressures force the decision now:
- The eight-repo project is on the runway. Buzzy (ADR-059
reference project — Product class) already spans
buzzy-frontendandbuzzy-backend; the mobile repo is planned. Focus spans three. Two features in the current backlog touch three repos each. The mental load of coordinating review across three open PRs without a shared preview is what breaks reviewers. - ADR-072 shipped the sub-playbook catalogue. The eight
sub-playbooks in ADR-072 §5 are exactly the pieces a preview
environment composes: one DNS record, one tunnel route, one
Access app, one Caddy site, one systemd-or-docker origin. A
preview environment is a
deploy-service(ADR-072 §6) with a per-PR namespace, a tighter TTL, and a more restrictive Access policy. We get preview environments nearly for free if we commit to the pattern before other patterns ossify. - Production Dockerfiles are landing. The Dockerfile-per-repo
PR across
buzzy-frontend,buzzy-backend,distill-gyrum-ai, and the others lets us build a single OCI image per commit that the preview stack can run. Without it we would need a per-repo "dev server" mode that is almost-but-not-quite production — exactly the divergence preview environments exist to eliminate. With it, preview runs the same image production will run. The only difference is the URL and the Access policy.
The fleet E2E compose (distill-gyrum-ai's e2e-integration/) is the
shape we generalise: a docker-compose stack that boots the production
images of every service the test touches, wires them together, and
exercises them with Playwright. A preview environment is the same
shape with per-PR namespacing on the compose project name, the
port block, the DNS record, the tunnel route, and the Access app —
and without the Playwright runner (Phase D re-introduces it).
This ADR commits the pattern and specifies the two playbooks that implement it.
Why not Vercel / Render / Heroku?
The obvious question and the right one to answer early. See §11 for the full argument; the short version is that Vercel covers the frontend beautifully and has no answer for the backend, Render and Heroku both require moving services off Hetzner (loss of the ADR-020 "shared infra on our VMs" stance), and all three introduce a third-party billing surface whose cost scales with PR rate. We already run the primitives (Cloudflare Tunnel, Access, Caddy, Weft runtime) that compose into preview environments; adopting a vendor for the preview story means we operate two preview systems (the vendor's for the frontend, our own for the backend) or we offload the whole thing and lose the control surface ADR-072 just committed to. We do neither.
Why now — and not after we hit pain?
Every PR merged without a preview is a data point that makes the "merge to main to find out" habit sturdier. The cost of adopting preview environments grows superlinearly with the rate of multi-repo PRs; the benefit starts the first time a reviewer catches a cross-repo regression on the preview URL instead of on production. Waiting for a concrete production incident to justify the work — the default we apply to most infra investments — is wrong for this class of tool, because the incident is exactly what preview environments prevent. We name this explicitly so the reviewer of this ADR does not ask "what breakage does this fix?" (the answer is: the breakage we are not yet measuring because we don't look for it during review).
The pattern
The pattern has six moving parts. Every one composes on pieces we already own: projects (ADR-059), playbooks (ADR-067/068/069), Cloudflare primitives (ADR-072), production Dockerfiles (the current Dockerfile-per-repo PR), and the fleet E2E compose shape.
1. Branch coordination — name-match across the project's repos
When a preview is requested for a PR feat/dark-mode on repo
buzzy-frontend, the preview stack needs to know which version of
every other repo in the buzzy project to use. The rule is:
For every repo listed in the project's repos: (ADR-059 §4), look
for a branch named feat/dark-mode. If the branch exists on that
repo, use its HEAD SHA. Otherwise use main on that repo.
This is the name-match convention and it is load-bearing. We
commit to it rather than to an alternative (branch annotations, a
.gyrum/preview.yaml declaring the cross-repo branch mapping,
manual operator input) for one reason: it matches how operators
already work. When an operator writes a feature that touches both
the frontend and the backend, they create the same branch name on
both repos — that is the existing muscle memory, not a new
discipline we need to teach. The name-match convention codifies the
muscle memory rather than layering a new annotation on top of it.
The coordinator is the projects-know-repos primitive already
satisfied by gyrum-projects (ADR-061). The preview-up playbook
takes a project_slug input, reads projects/<slug>.yaml, iterates
its repos: list, and for each repo queries the GitHub API for the
branch by name. The output of this phase is the
repos_with_branches[] input for the rest of the playbook — a list
of {repo, sha} tuples that is the exact thing docker-compose
needs in order to pull the right images.
Edge cases, committed:
- Branch exists on an unrelated repo. If
buzzy-frontendhasfeat/dark-modeandai-research-run(a Platform repo not in Buzzy's project but which has a coincidentally-named branch) does too — we only consult repos listed in the triggering project'srepos:. Cross-project branch-name collision does not matter. - Branch exists with the same name but a different author. The author of the triggering PR is taken from the GitHub webhook; we do not cross-check authorship against the name-matched branch on the other repo. If two operators chose the same branch name on two different repos for unrelated work, they collide — but that is the same breakage they would have if they both tried to work on the same branch name on the same repo. Not a new failure mode.
- Branch deleted mid-preview. Rare. If a name-matched branch on
a sibling repo is deleted between preview-up and the next
webhook-driven redeploy, the next redeploy falls back to
mainfor that repo. The preview URL does not change; the composition does. We log this as apreview_repo_fallbackevent so the operator can notice.
2. Naming — preview URLs
Every preview gets a stable URL for the lifetime of the PR. The URL format is:
https://<slug>-pr<N>.preview.gyrum.ai
Where <slug> is the triggering project's slug (ADR-059 §4 —
e.g. buzzy, focus) and <N> is the PR number on the triggering
repo. Example: buzzy-pr142.preview.gyrum.ai.
Why not <shortsha> in the hostname? We considered
preview-<pr-number>-<shortsha>.gyrum.ai and rejected it for three
reasons. First, the SHA changes on every push, which would mean a
new DNS record, a new Tunnel route, and a new Access app for every
commit — three extra mutations per push. Second, a stable hostname
lets the PR description link the preview URL once and have it keep
working for the life of the PR; reviewers who bookmark it do not
get stale links. Third, the SHA adds zero isolation — the Access
policy is already per-PR-author-plus-reviewers, not per-SHA. The
SHA goes in the preview environment's /api/version output (ADR-072
verify_sha step), which is where operators check "am I looking
at the right commit?"
Why a dedicated preview.gyrum.ai subdomain (not *.gyrum.ai or
pr142.buzzy.gyrum.ai)? The subdomain namespace is the isolation
boundary. A lint-bypass bug in a preview playbook that somehow
issues a DNS record for *.gyrum.ai is constrained to the
preview.gyrum.ai zone by scoped tokens (ADR-072 §2.4). Production
hostnames (buzzy.gyrum.ai, weft.gyrum.ai) live on
gyrum.ai directly; the two zones are separate Cloudflare zones with
separate API tokens. A preview environment cannot, by construction,
mint a DNS record that affects production. This pattern mirrors the
weft.gyrum.ai pattern ADR-071 §5 introduced (the product's own
subdomain is its own namespace).
Apex preview.gyrum.ai itself is a static landing page
(Caddy-served) listing "what is this?", "how do I get access?", and
a contact link. Not a bare NXDOMAIN — that would confuse search
engines and any operator who hits the apex by accident.
3. Isolation — each preview is its own namespace
No two previews can contaminate each other. Contamination includes: shared database state, shared ports, shared tunnel routes, shared Access policies, shared Caddy site blocks, shared filesystem. The isolation contract is:
- Docker-compose namespace. Each preview has a compose project
name of
preview-<project_slug>-pr<N>(e.g.preview-buzzy-pr142). All containers, networks, and named volumes are prefixed by this.docker compose -p preview-buzzy-pr142 upon the Hetzner box lives in its own network namespace; inter-preview traffic is impossible without cross-namespace routing we never set up. - Port block. Each preview gets a dedicated 10-port range,
assigned at preview-up time from a pool. The range is stored in a
preview_port_allocationstable in Postgres (a new one-off table owned by the preview-up playbook; lives indark-factory's application DB alongsideplaybook_runs). Allocation is first-fit; deallocation on teardown. The pool size is 100 ports in blocks of 10 — enough for 10 concurrent previews even though the capacity cap (§8) is 4 today. - Ephemeral Postgres. Each preview's
docker-compose.ymldeclares its own Postgres instance, in its own container, with its own named volume. The volume is namedpreview_buzzy_pr142_pg_data, prefixed as above, and is deleted at teardown. No preview reads from the production database; no preview writes to a shared staging database. - Tunnel route — one per preview.
cloudflare-tunnel-route-upsert(ADR-072 §5.3) adds<slug>-pr<N>.preview.gyrum.ai→http:// localhost:<preview-port-8080>to the ingress list of a dedicated preview tunnel (preview.gyrum.aitunnel, separate from the production tunnel). One tunnel, many ingress entries, each entry scoped to one preview. Concurrent preview-up runs race on the tunnel config; ADR-072 §5.3'sIf-Match: ${etag}pattern serialises them, retry on conflict. - Access app — one per preview.
cloudflare-access-app-upsert(ADR-072 §5.4) creates an Access app for the preview's hostname with a policy allowing the PR author's email plus the emails of the PR's reviewers (read from GitHub API at preview-up time). - Caddy site — one per preview.
caddy-site-upsert(ADR-072 §5.6) adds a site block for the preview hostname on the Hetzner box, reverse-proxying to the preview's allocated port. This is how the tunnel termination inside the Hetzner box reaches the right container. - Filesystem. All preview state — compose files, per-preview
.envfiles with the generated secrets — lives under/var/lib/gyrum/previews/preview-<slug>-pr<N>/. Teardownrm -rfs the directory. A preview cannot read another preview's state.
No cross-preview state. No "staging is shared so everyone sees everyone's data" footgun. The cost is 2GB of RAM per preview (§8) rather than the ~500MB a shared-staging environment would use; we take that trade explicitly.
4. Access — always gated, never public
Preview URLs are not public. Every preview's Access policy
(cloudflare-access-app-upsert, ADR-072 §5.4) includes:
- The PR author's email (from GitHub API).
- The email of every explicit reviewer on the PR (from GitHub API at preview-up and on every webhook-driven redeploy, so adding a reviewer grants them access within ~10 seconds of the review- request event).
- Any email matching the
@gyrum.aidomain (internal team access for cross-PR review).
Never public. An operator who clicks the preview URL without
being signed in to Access gets the standard Cloudflare Access
login page, authenticates, and — if their email does not match the
policy — sees the "you are not authorised" page. The URL is not
leaked to search engines (via X-Robots-Tag: noindex set by Caddy
at the preview site block) and the preview origin does not serve
any response before Access has satisfied the policy at the edge.
Why not optional? Because the preview environment runs
unauthenticated code paths (migration scripts, seed data generation,
admin endpoints exposed for preview debugging) that are absolutely
not safe to put on the public internet, even behind a long
hostname. The policy is "gated by default, no opt-out"; a
preview environment that a reviewer wants to share with an
external collaborator is a new explicit cloudflare-access-policy- upsert call to add the collaborator's email, not a flag to make
the preview public.
5. Lifecycle — the four transitions
Every preview has exactly four lifecycle transitions. Each is a trigger that invokes one of the two playbooks:
| Transition | Trigger | Playbook invoked | Inputs |
|---|---|---|---|
| PR open → preview up | GitHub webhook: pull_request.opened, pull_request.reopened, pull_request.ready_for_review |
release_flow:preview-up |
pr_number, project_slug, repos_with_branches[] |
| PR update → preview redeployed | GitHub webhook: pull_request.synchronize (new commits pushed), pull_request.review_requested (new reviewer added) |
release_flow:preview-up (idempotent re-run with updated inputs) |
same + fresh reviewers[] and repos_with_branches[] |
| PR close or merge → preview down | GitHub webhook: pull_request.closed (regardless of merged or not) |
release_flow:preview-down |
pr_number |
| Age > 72h → preview down | Cron (hourly) — query preview registry, find any preview_up_at + 72h < now(), invoke teardown |
release_flow:preview-down |
pr_number per stale preview |
The 72-hour timeout is always enforced, no override. A PR that
needs its preview alive longer than 72 hours needs a new push (which
resets the clock, per preview_up_at = now() on every
preview-up run). This is deliberate: a preview that stays alive for
a week is a preview the reviewer has lost interest in, and it is
consuming capacity (§8) another reviewer needs. The forced
refresh-or-die pattern prevents zombie environments. We chose 72h
rather than 24h or 7d after looking at what other teams do (Render
defaults to 72h inactivity; Vercel has no limit but they have
unlimited capacity; Heroku legacy was 24h and operators complained):
72h is long enough for a weekend review cycle, short enough to
force operator action rather than indefinite drift.
Idempotency. Both playbooks are idempotent in the ADR-072 §2.1
sense. preview-up on a preview that already exists with the same
SHAs and reviewers is a no-op: every sub-playbook reports
changed: false. preview-up after a webhook push with a new SHA
computes a delta on the deploy_app step and applies only that
step — DNS, Tunnel, Access, Caddy all report changed: false.
preview-down on a preview that does not exist is also a no-op.
This matters because webhooks can be delivered more than once
(GitHub retries); the idempotent playbook makes duplicate delivery
safe without an idempotency-key layer.
6. Authenticated ingress — webhook secret + replay safety
The GitHub webhook that drives the lifecycle posts to a small endpoint (Phase B — §6 below). That endpoint MUST verify:
- The
X-Hub-Signature-256HMAC over the request body, using a webhook secret stored in the ADR-069 vault asvault.github_ preview_webhook_secret. A request without a valid signature is dropped with 401 and aplaybook_run_eventsentry ofpreview_webhook_rejected. - The
X-GitHub-Deliveryid is recorded in apreview_webhook_deliveriestable. A second delivery with the same id is acknowledged with 200 (to stop GitHub's retry loop) but does not re-invoke the playbook — idempotency-by-id belt-and- braces on top of the playbook's own idempotency (§5). - The
repository.full_nameandpull_request.base.repo.owner. loginmatch the allow-list of repos we accept webhooks for. Only repos listed in anyprojects/*.yamlunderrepos:are accepted. An unknown repo's webhook is rejected with 404 (not 401 — the signature may be valid, the sender is just the wrong sender).
The webhook secret is rotated every 90 days; rotation is a
separate playbook (github-webhook-secret-rotate) following the
ADR-072 scoped-token pattern. Out of scope for this ADR.
The two new playbooks — specs (not full YAML)
Both playbooks live in dark-factory/playbooks/release_flow/ — they
are kind: release_flow (ADR-072's deploy-service is also this
kind), distinct from kind: service_runbook (the infra sub-
playbooks). Release flows orchestrate; sub-playbooks mutate one
surface.
Full YAML bodies are explicitly out of scope for this ADR and land in two follow-up PRs (one per playbook, each ≤ 800 lines per ADR- 067's PR cap), each citing ADR-073.
A. release_flow:preview-up
---
id: preview-up
kind: release_flow
title: "Preview: bring up an ephemeral full-stack preview for a PR"
persona: sre # manual invocation today; webhook-driven Phase B
owner: "@jon"
status: active
trigger: "Invoked by the preview-webhook-receiver on pull_request.opened/synchronize/reopened/ready_for_review/review_requested events, or manually by an SRE for bootstrap/debug"
success_metric: "Preview reachable at https://<slug>-pr<N>.preview.gyrum.ai, returns the PR's HEAD SHA on /api/version, Access policy includes PR author + reviewers"
tags: [infra, preview, release_flow, release]
allowed_personas: [sre, owner]
allow_local_execution: false
# Union of sub-playbook required_scopes (ADR-072 §2.4 — computed at parse time)
required_scopes:
- {vendor: cloudflare, scope: "Zone:DNS:Edit", zone_filter: "${env.CF_ZONE_ID_PREVIEW}"}
- {vendor: cloudflare, scope: "Account:Cloudflare Tunnel:Edit"}
- {vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}
- {vendor: ssh, scope: "host:ops-vps:sudo:caddy"}
- {vendor: ssh, scope: "host:ops-vps:sudo:docker"}
- {vendor: github, scope: "repo:read"}
runtime:
inputs:
- {name: pr_number, type: int, required: true}
- {name: project_slug, type: string, required: true}
- {name: triggering_repo, type: string, required: true}
- {name: dry_run, type: bool, default: false}
steps:
# 1. Resolve cross-repo branch state (the name-match rule)
- id: resolve_repos_with_branches
type: http # GitHub API
# for each repo in projects/<slug>.yaml :repos:, GET /repos/{r}/branches/feat/dark-mode
# output: repos_with_branches[] = [{repo, sha}, ...]
save_as: repos
# 2. Resolve reviewers from the PR
- id: resolve_reviewers
type: http
# GET /repos/{triggering_repo}/pulls/{pr_number} - pluck author + reviewers
save_as: reviewers
# 3. Allocate a port block (or reuse an existing allocation for this PR)
- id: allocate_ports
type: http # calls the preview-registry service
# POST /previews/{pr_number}/port-allocation — idempotent
save_as: ports
# 4. DNS — upsert <slug>-pr<N>.preview.gyrum.ai → preview tunnel
- id: ensure_dns
type: subplaybook
playbook: service_runbook:cloudflare-dns-upsert
with:
zone_id: "${env.CF_ZONE_ID_PREVIEW}"
name: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
type: CNAME
content: "${env.CF_PREVIEW_TUNNEL_ID}.cfargotunnel.com"
proxied: true
dry_run: "${inputs.dry_run}"
save_as: dns
# 5. Tunnel — upsert an ingress entry for this hostname → preview caddy port
- id: ensure_tunnel_route
type: subplaybook
playbook: service_runbook:cloudflare-tunnel-route-upsert
with:
tunnel_id: "${env.CF_PREVIEW_TUNNEL_ID}"
hostname: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
service_url: "http://localhost:${ports.caddy_port}"
dry_run: "${inputs.dry_run}"
save_as: tunnel
# 6. Access — gate the hostname behind author + reviewers + @gyrum.ai
- id: ensure_access
type: subplaybook
playbook: service_runbook:cloudflare-access-app-upsert
with:
domain: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
name: "preview-${inputs.project_slug}-pr${inputs.pr_number}"
allowed_emails: "${reviewers.emails + ['*@gyrum.ai']}"
policy_name: "preview-author-and-reviewers"
dry_run: "${inputs.dry_run}"
save_as: access
# 7. Render docker-compose.yml for this preview
- id: render_compose
type: shell
target: host:ops-vps
# generates /var/lib/gyrum/previews/preview-<slug>-pr<N>/docker-compose.yml
# with per-repo image tags (repos_with_branches[].sha) and the allocated ports
save_as: compose
# 8. docker compose up (production images, per-PR namespaced)
- id: bring_up_stack
type: ssh
target: host:ops-vps
# docker compose -p preview-<slug>-pr<N> -f <compose_path> up -d --pull always
when: "!inputs.dry_run"
save_as: stack
# 9. Caddy — reverse-proxy the preview hostname to the preview's caddy port
- id: ensure_caddy
type: subplaybook
playbook: service_runbook:caddy-site-upsert
with:
host: ops-vps
hostname: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
backend_url: "http://localhost:${ports.caddy_port}"
dry_run: "${inputs.dry_run}"
save_as: caddy
# 10. Wait-ready — poll /api/version until it returns, max 90s
- id: wait_ready
type: shell
target: local
when: "!inputs.dry_run"
# for i in 1..45; curl -sf https://<host>/api/version && exit 0; sleep 2
timeout: 90s
# 11. Verify — /api/version returns the triggering repo's HEAD SHA
- id: verify_sha
type: http
when: "!inputs.dry_run"
# GET /api/version — assert body.sha == repos.by[triggering_repo].sha
save_as: verify
# 12. Register — record the preview in preview_registry for the UI and reconciler
- id: register
type: http
# POST /previews/{pr_number} {url, up_at, reviewers, repos}
save_as: registered
outputs:
preview_url: "https://${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
pr_number: "${inputs.pr_number}"
up_at: "${registered.up_at}"
repos: "${repos}"
reviewers: "${reviewers.emails}"
dns_changed: "${dns.outputs.changed}"
tunnel_changed: "${tunnel.outputs.changed}"
access_changed: "${access.outputs.changed}"
caddy_changed: "${caddy.outputs.changed}"
stack_changed: "${stack.outputs.changed || false}"
dry_run: "${inputs.dry_run}"
---
Shape notes:
- Steps 4-6 and 9 are
subplaybook:calls into the ADR-072 catalogue. Zero new infra mutators are introduced. - Steps 1-3, 7-8, 10-12 are the preview-specific logic: cross- repo branch resolution, port allocation, compose rendering, docker up, verify, registry.
dry_runthreads through exactly as ADR-072 §2.2: the operator can runpreview-upin dry-run mode to see what would happen without mutating the system. In Phase A this is how an SRE validates the playbook before letting webhooks drive it.
B. release_flow:preview-down
---
id: preview-down
kind: release_flow
title: "Preview: tear down an ephemeral preview and release its resources"
persona: sre
owner: "@jon"
status: active
trigger: "Invoked by the preview-webhook-receiver on pull_request.closed, or by the preview-reaper cron on 72h timeout"
success_metric: "DNS, Tunnel ingress, Access app, Caddy site, and docker-compose stack for the preview are all deleted; port block returned to pool"
tags: [infra, preview, release_flow, teardown]
destructive: true # ADR-069 §6 — all teardown playbooks are destructive
allowed_personas: [sre, owner]
allow_local_execution: false
required_scopes:
- {vendor: cloudflare, scope: "Zone:DNS:Edit", zone_filter: "${env.CF_ZONE_ID_PREVIEW}"}
- {vendor: cloudflare, scope: "Account:Cloudflare Tunnel:Edit"}
- {vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}
- {vendor: ssh, scope: "host:ops-vps:sudo:caddy"}
- {vendor: ssh, scope: "host:ops-vps:sudo:docker"}
runtime:
inputs:
- {name: pr_number, type: int, required: true}
- {name: project_slug, type: string, required: true}
- {name: dry_run, type: bool, default: false}
steps:
# 1. Read preview registration (is there a preview for this PR?)
- id: read_registration
type: http
# GET /previews/{pr_number}
# if 404 → preview already down; remaining steps all no-op via when:
save_as: reg
on_error: continue
# implicit approval step — ADR-069 §6 (destructive: true)
# prompt: "Tear down preview for PR #${pr_number} at ${reg.url}? This is irreversible."
# skipped when dry_run: true OR when reg.status == 404
# 2. Docker compose down (stop and remove the stack + its volumes)
- id: tear_down_stack
type: ssh
target: host:ops-vps
when: "reg.status != 404 && !inputs.dry_run"
# docker compose -p preview-<slug>-pr<N> -f <compose_path> down -v
# 3. Caddy — delete the site block
- id: delete_caddy
type: subplaybook
playbook: service_runbook:caddy-site-delete
with:
host: ops-vps
hostname: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
dry_run: "${inputs.dry_run}"
when: "reg.status != 404"
# 4. Access — delete the app (which cascades its policies)
- id: delete_access
type: subplaybook
playbook: service_runbook:cloudflare-access-app-delete
# NOTE: not in the ADR-072 §5 catalogue today; adding it is part of the
# preview-down follow-up PR — a straightforward delete-by-id sibling of
# cloudflare-access-app-upsert following the §3 shape.
with:
domain: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
dry_run: "${inputs.dry_run}"
when: "reg.status != 404"
# 5. Tunnel — remove the ingress entry
- id: delete_tunnel_route
type: subplaybook
playbook: service_runbook:cloudflare-tunnel-route-delete
# NOTE: also a new sibling playbook — the route-upsert removes the entry
# by setting service_url: null in the ingress list, symmetric to §5.3.
with:
tunnel_id: "${env.CF_PREVIEW_TUNNEL_ID}"
hostname: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
dry_run: "${inputs.dry_run}"
when: "reg.status != 404"
# 6. DNS — delete the CNAME
- id: delete_dns
type: subplaybook
playbook: service_runbook:cloudflare-dns-delete
with:
zone_id: "${env.CF_ZONE_ID_PREVIEW}"
name: "${inputs.project_slug}-pr${inputs.pr_number}.preview.gyrum.ai"
dry_run: "${inputs.dry_run}"
when: "reg.status != 404"
# 7. Filesystem — rm -rf /var/lib/gyrum/previews/preview-<slug>-pr<N>/
- id: delete_fs
type: ssh
target: host:ops-vps
when: "reg.status != 404 && !inputs.dry_run"
# 8. Release port block back to pool
- id: release_ports
type: http
# DELETE /previews/{pr_number}/port-allocation
when: "reg.status != 404"
# 9. Deregister — remove from preview_registry
- id: deregister
type: http
# DELETE /previews/{pr_number}
when: "reg.status != 404"
outputs:
pr_number: "${inputs.pr_number}"
torn_down: "${reg.status != 404}"
dns_changed: "${dns.outputs.changed || false}"
tunnel_changed: "${tunnel.outputs.changed || false}"
access_changed: "${access.outputs.changed || false}"
caddy_changed: "${caddy.outputs.changed || false}"
dry_run: "${inputs.dry_run}"
---
Shape notes:
- Destructive: true — every
preview-downinvocation goes through an ADR-069 §6 implicit approval step. When the webhook drives it in Phase B, the approval is auto-approved by the webhook-receiver service (using the ADR-069 approval-token pattern). Manual invocations pause for an operator click. Cron- driven (72h timeout) runs use the same service-token auto- approval as the webhook. reg.status != 404guards every destructive step. If the preview registry says "no such preview" the playbook short- circuits to a no-op. This is the idempotency for teardown: runningpreview-downon an already-torn-down preview is safe.- Two new sub-playbooks are named —
cloudflare-access-app- deleteandcloudflare-tunnel-route-delete. They are straight destructive siblings of the §5.4 and §5.3 upserts in ADR-072; adding them to the catalogue is part of the preview-down follow-up PR and does not require a new ADR — ADR-072 §11 and §12 explicitly contemplate the catalogue growing with additions.
Both playbooks share the _index.yaml + related: convention
from ADR-067 §6 — they cross-reference each other, ADR-072's sub-
playbooks, and this ADR.
Implementation plan — phased
Each phase is independently useful. No phase blocks the next; each is a separately-shippable PR or cohort of PRs.
Phase A — manual invocation (committed)
- Both playbooks land as YAML under
dark-factory/playbooks/ release_flow/. They are runnable from the Weft<PlaybookRunner>(ADR-070 §1) by an operator typing the PR number and project slug into the form. - An operator validates the full flow by hand: open a test PR on
buzzy-frontend, runpreview-upfrom the runner, visit the preview URL, authenticate through Access, verify the stack is up, close the PR, runpreview-downfrom the runner, verify nothing lingers (DNS record gone, tunnel ingress gone, Access app gone, Caddy site gone, docker-compose stack gone). - Dry-run mode (ADR-072 §2.2) is the safety net — the first
invocation of each playbook runs in dry-run, the delta is
inspected, then the operator re-runs with
dry_run: false. - Exit criteria: one manual cycle (up → visit → down) with
green on every sub-playbook's
changedflag. A second cycle on the same PR with no code change reports every sub-playbookchanged: falseon the up step — idempotency verified.
Phase B — webhook-driven (committed)
- A small Go service,
preview-webhook-receiver, lives indark-factory/cmd/preview-webhook-receiver/. It is a single HTTP endpointPOST /webhooks/githubthat verifies the HMAC (§6), records the delivery id, maps the event to a playbook invocation (pull_request.opened|reopened|synchronize| ready_for_review|review_requested→preview-up;pull_request.closed→preview-down), and invokes the playbook via the ADR-068 runtime's internal API (POST /playbook-runswith the input form filled). - The service is deployed the ADR-072 way: a
deploy-serviceplaybook invocation places it behind Access (policy: only the GitHub webhook source IP ranges, cross-checked with the HMAC) on a dedicated hostname (github-webhooks.gyrum.ai), reverse- proxied by Caddy, backed by a systemd unit, receiving the webhook secret via ADR-069 vault resolution. - A GitHub repository webhook is configured (via a playbook —
github-repo-webhook-upsert, new) on every repo that any project'sprojects/*.yamllists underrepos:. - The reaper cron lands: an hourly systemd timer that queries
the preview registry, finds previews with
up_at + 72h < now(), and invokespreview-downfor each. Same invocation path as the webhook. - Exit criteria: opening a PR on
buzzy-frontendautomatically produces a preview URL within 120 seconds; the URL is posted as a comment on the PR by the webhook-receiver (to save reviewers a lookup). Closing the PR tears the preview down within 60 seconds. A PR open-and-forgotten is reaped within 1 hour of its 72h anniversary.
Phase C — /ops/previews UI (named horizon)
- A new screen in the Weft frontend (ADR-070 contract), under
the SRE persona (ADR-065 persona IA), at
/ops/previews. - Reads the preview registry. Renders a table: preview URL, PR
link, project, triggering repo, opened-at, expires-at,
reviewers, size-on-disk. Each row has a "tear down now" button
that invokes
preview-downthrough the runner. - Live-updates via the SSE channel (ADR-068 §5): a preview-up completing anywhere in the fleet pushes a row; a preview-down removes it.
- Exit criteria: an SRE can answer "what previews are live right now?" from one screen, including RAM pressure and time- until-reap. Tear-down from the UI produces the same audit trail as the webhook-driven or manual path. No bypass of destructive-step approval — the UI button goes through the same ADR-069 §6 approval.
Phase D — Playwright against the preview URL (named horizon)
- The AI review flow (
gyrum-review-pr, ADR-067-ish review gate) gets a new step: when a preview URL is live for the PR, run the Playwright suite (the shape ofdistill-gyrum-ai/e2e-integration/tests/) against the preview URL instead of against a local stack. - The Playwright agent signs in via an Access service token (provisioned by a playbook, short TTL, scoped to the preview's domain).
- Test results post back as a PR comment — green = ✓, red = the failing test name + link to the Playwright trace.
- Exit criteria: every multi-repo PR inside a project automatically gets cross-browser E2E evidence on its review, without any reviewer having to run anything locally.
Phases A and B are committed to this cycle. Phases C and D are named horizons: they are in this ADR because they are the natural evolution of A+B, not because we commit to shipping them on a specific date. The ADR-073 re-review at T+90 days (§13) reassesses whether C and D are due.
Capacity planning — one Hetzner box
The first-order question: how many previews can we host concurrently?
Our Hetzner box is a CX52 (16 vCPU, 32GB RAM, 320GB SSD). Production
services on it today: weft.gyrum.ai stack (~4GB), buzzy.gyrum.ai
stack (~3GB), observability stack (~5GB), Postgres primary (~4GB),
and headroom (~4GB system). That leaves ~12GB free, of which we
reserve 4GB as a safety margin. 8GB available for previews.
A typical preview (Buzzy today — frontend + backend + Postgres + Redis + Caddy) consumes ~2GB RAM. A preview for a heavier project (Focus — three containers + shared Postgres) consumes ~3GB.
Phase 1 cap: 4 concurrent previews (worst-case: 4×2GB = 8GB). This is the soft cap the webhook-receiver enforces. A fifth preview-up request queues (§below) until a slot opens.
Queue policy: FIFO. First PR opened is first PR served. The
webhook-receiver has a preview_queue table with (pr_number, project_slug, enqueued_at, status) rows and a tiny worker loop
that pops the head when the active-preview count drops below 4.
The webhook-receiver posts a comment on queued PRs — "preview
queued, position N in line" — so reviewers know the delay is a
capacity-shape problem rather than a failure.
Overflow handling: if a preview has been up for > 24h AND the
queue depth is > 0, the webhook-receiver invokes preview-down on
the oldest-up-at preview and the queue advances. This overrides
the 72h cap downward to 24h under capacity pressure. The preview
whose owner loses their environment gets a PR comment —
"preview torn down to make room; push a commit or re-open to
restore". The owner always has recourse: pushing a commit re-
queues them at the tail.
Monitoring: the preview_registry emits a Prometheus gauge
preview_active_count and a gauge preview_queue_depth. The
ADR-055 observability stack scrapes both. An alert fires when
preview_active_count == 4 AND preview_queue_depth > 2 for > 15
minutes — that's the signal that 4 is too few and we need either a
second box or a harsher reaper.
When 4 stops being enough: two mitigations, in order of
preference. First, drop the 72h default to 48h if many previews
sit idle late in their window (reduces average RAM footprint).
Second, provision a second preview-dedicated box (a CX31 at ~€16/
month adds another ~10GB preview budget). The second box is
introduced via a new preview-host-provision playbook and the
compose-target-host selection in preview-up step 8 becomes a
scheduler call rather than a hard-coded ops-vps. Neither
mitigation is in scope for this ADR; both are named.
Cost model
Cloudflare Tunnel is free at our scale (the free tier allows unlimited tunnels; bandwidth is free). Cloudflare Access is free up to 50 users on the free plan, which covers the Gyrum team plus external reviewers many times over. DNS is free. Hetzner RAM is fixed — we are already paying for the 32GB; preview workload consumes slack rather than incremental cost. Docker images are built once per push by the existing CI (cost already sunk); the preview pulls them. The only incremental cost is disk — ~5GB per preview for image layers + Postgres volume, which at four concurrent is 20GB on a 320GB disk. Rounds to zero.
Total preview incremental cost: ≈ zero. If/when we outgrow the CX52, the second-box cost is € 16/month. This is the cheapest review-app story we could credibly ship; it exists only because ADR-020 committed to the on-VM model and ADR-072 shipped the primitives.
Consequences
Easier.
- Every multi-repo PR is tested in context, automatically. The reviewer opens the preview URL, clicks around, and sees the PR's code running end-to-end against the PR's schema against the PR's assets. The "merge to main to find out" muscle atrophies.
- Review evidence is a link, not a checkout. Reviewers paste the preview URL in their review comments; the evidence is reproducible by the author, the other reviewers, and the reviewer's future self.
- AI reviewers get a URL. Phase D's Playwright-against-preview is the specific unlock: the AI reviewer does not need a local stack spin-up, which means AI review is no longer gated by "is the local fleet happy today". A remote, cheap, always-fresh environment is exactly what a headless reviewer needs.
- New SRE onboarding drops. "How do I test a frontend + backend PR?" is now a one-line answer — "open the preview URL in the PR description, sign in through Access" — rather than a ten-minute checkout-and-compose.
- Cross-project coordination is visible. The name-match rule
means that if a PR is labelled
feat/dark-modeon bothbuzzy-frontendandbuzzy-backend, the preview runs both of them, and breakage between the two surfaces on the preview URL before breaking on main. A feature that touches three repos gets three repos' worth of shared evidence in one place.
Harder.
- Webhook latency is real. PR open → preview URL takes ~90 seconds end-to-end (image pull dominates at ~60s, the rest is CF propagation + Access app creation + Caddy reload + wait- ready). An operator who opens a PR and immediately looks for the preview URL waits. We document the expected timing in the webhook-receiver's PR comment so the expectation is set: "preview coming up, ~90s."
- State drift. If an operator manually edits the preview's
Cloudflare Access app in the UI (say, to add an external
collaborator), the next
preview-uprun — triggered by a push on the PR — recomputes the policy fromreviewers.emails + ['*@gyrum.ai']and overwrites the manual addition. This is the ADR-072 §11 drift problem; it is not new, and the same rule applies: if you clicked the UI, you owe a PR (to the reviewers list, or to the playbook itself). We document this in the preview UI's "share with external reviewer" affordance once Phase C lands. - Teardown discipline matters. If a
preview-downplaybook crashes between steps — say, betweendelete_accessanddelete_dns— the Access app is gone but the DNS record lingers. The reaper cron (Phase B) is the safety net: it periodically (hourly) reconciles the preview registry against the Cloudflare state and invokespreview-downagain on any preview whose registry row says "up" but whose<slug>-prN.preview.gyrum.aireturns NXDOMAIN-via- origin-down. Zombie DNS + Tunnel entries without a registry row are a separate sweep; a follow-uppreview-zombie-reapplaybook (out of scope for this ADR) handles that cleanup. - Capacity is a first-class concern. Four concurrent previews is not "unlimited". A team week where five reviewers each have two open PRs is a queue event. We explicitly chose to ship the queue rather than provision for a never-queue capacity, because FIFO + a visible PR comment is a better first-ship than capacity-for-the-tail.
- Secrets management is now more surface. Every preview
stack has its own per-preview
.envcontaining generated DB passwords and a JWT secret. These live on the Hetzner box under/var/lib/gyrum/previews/preview-<slug>-pr<N>/. The teardownrm -rfs the directory but a reading-before- teardown from a compromised account would expose the preview's secrets — low-value targets (short-lived, gated, no prod data) but surface nonetheless. Mitigation: the per-preview secrets are never referenced from anywhere except the preview's own compose file, and the file-mode on the.envis0600owned by the runtime worker user.
What we sign up to maintain.
- The two playbooks (
preview-up,preview-down) with all the idempotency, dry-run, and audit guarantees of ADR-072 §2. Every change to either playbook is a PR, reviewed, CI-checked for shape conformance. - Two new sub-playbooks in the ADR-072 catalogue:
cloudflare-access-app-deleteandcloudflare-tunnel-route- delete. These are straightforward destructive siblings of the existing upserts and follow the §3 shape. - The webhook-receiver service. A ~500-line Go service that
does HMAC verification, delivery-id dedup, and playbook
invocation. Owned like any other gyrum service: versioned,
observability-wired (ADR-053), behind Access, deployed via
deploy-service. - The preview registry. A small table in the dark-factory app DB plus a thin service API. Referenced by the webhook, the reaper, the UI (Phase C). We resist the urge to make this a separate microservice; it is a table and a couple of handlers.
- The 72h + 24h-under-pressure cadence. These numbers were chosen deliberately (§5, §8). If they turn out wrong in practice — previews being reaped during an in-progress review, or previews surviving too long and bloating capacity — the review cadence (§13) re-tunes them. We track them as parameters, not as ADR amendments.
- The name-match convention. The rule "same branch name on
every repo in the project" is a team discipline — a
documented convention in
docs/shared/preview-environments.md(a follow-up doc) that every contributor learns. A first- time failure (a PR opened on one repo with a branch name that collides with an unrelated feature on a sibling repo) is a retro item, not a bug in the playbook.
Alternatives considered
Kubernetes-based preview environments (Tekton + Argo, or Jenkins X). Rejected. The k8s preview-env pattern is mature and battle-tested — Argo CD + an ApplicationSet generator per-PR is a well-trodden path. But Gyrum is not Kubernetes- native (ADR-020 commits to VMs + systemd + Caddy + Docker). Adopting k8s for the preview story means either running a control-plane cluster (all of the k8s ops overhead for exactly one use case) or paying for a managed control plane (external dependency for an internal concern, and a paywall whose cost scales with cluster size). The preview use case alone does not justify becoming a k8s shop. If we become a k8s shop for other reasons (unlikely per ADR-020 but reserve-the-right), the preview environment work done here — name-match, per-PR namespacing, Access-gated URL — ports to ApplicationSet generators cleanly.
Vercel (frontend only) plus some backend story. Considered. Vercel's preview story for the frontend is genuinely excellent — every PR gets a preview URL automatically, zero config. The reasons we reject it:
- It doesn't cover the backend. A Buzzy PR that touches
both
buzzy-frontendandbuzzy-backendneeds both running against each other. Vercel runs the frontend; the backend is our problem. We would end up with two preview systems — Vercel's frontend, our own backend — and the glue (pointing the Vercel preview frontend at the self-hosted backend preview) is exactly the multi- repo coordination Gordian knot we are trying to solve once. - Third-party billing surface. Vercel charges per build-minute above a free threshold. Four PRs a day at ~2 build-minutes each is ~240 minutes/month per repo. Across five product repos, that's 1,200 minutes — past the free tier. The annual cost is non-trivial and it scales with PR rate — exactly the wrong cost shape for a review primitive.
- Loss of the control surface. ADR-072 committed us to
"every infra mutation is a playbook". A preview
environment that is half-Vercel, half-Gyrum is a preview
environment where half of the state lives in a vendor's
database and the other half in
playbook_run_events. The audit story splits; the rollback story splits; the drift story splits.
Vercel is excellent at the shape of preview environments it solves, and we do not adopt it because our shape is "multi- repo, backend included, on our VMs, in our audit log". The escape hatch: if our preview capacity becomes a serious bottleneck and Vercel-for-frontend-only materially accelerates the review loop, we revisit — the decision boundary is "backend still on our VMs, Vercel proxied for frontend only". We do not cross that boundary now.
- It doesn't cover the backend. A Buzzy PR that touches
both
Render.com's preview environments. Considered. Render does frontend and backend review apps, has PR webhooks, has Access control (HTTP basic auth; not as good as Cloudflare Access). Rejected for the same reason as Vercel (migration off Hetzner) plus: Render's preview cap on the free tier is low and paid previews are billed per-container-hour, which scales with PR rate in exactly the wrong direction. And: we would be forced to migrate the production stack to Render to get preview parity, which is a much larger decision than this ADR.
GitHub Actions environments (GitHub-hosted preview infrastructure). Considered. GitHub offers ephemeral environments tied to Actions, with environment protection rules and secrets per environment. Rejected because:
- Gated behind GitHub's capacity and IP allowlisting. The preview environment would run on GitHub-hosted runners or self-hosted runners that are GitHub- discoverable, neither of which is a stable home for a stack that reviewers need to visit in a browser.
- Not self-hosted. ADR-020 is again the line. A preview environment that only exists inside the GitHub Actions runner is not a URL reviewers can hit from their browsers unless we proxy it out, at which point we are running the proxy ourselves, at which point we might as well run the stack ourselves.
- Worse audit story. Actions-log audit is per-run,
per-workflow — not a unified fleet audit. Our playbook
playbook_run_eventsgives us one place to look; Actions audit is another.
Docker-compose on the reviewer's laptop (current status quo). Rejected because it is the status quo and the status quo is exactly what this ADR is trying to move past. Local compose is fine for the author's own inner loop; it is not a review primitive because the reviewer is not the author.
Shared staging environment rather than per-PR previews. Rejected. A single shared staging environment is what we have today (the production-adjacent Hetzner slice); reviewing on it means reviewers step on each other's pushes. The per-PR-ness is the innovation — each review is isolated from every other. Shared staging also means destructive tests (migrations, seed rewrites) are dangerous in exactly the way per-PR isolation removes.
A single "long-lived preview" per project, updated from the PR branch. Rejected. This is a half-step: the preview exists per-project rather than per-PR, so it still serialises reviewers of different PRs on the same project. The cost of going full per-PR is marginal (same playbooks, smaller queue) and the benefit is much larger (parallel review).
Opt-in previews (operator must click a button on the PR to get a preview). Rejected. Opt-in means some PRs have a preview and some don't, which means reviewers have to check "does this one have one?" on every review. The cost of a preview is low enough (~2GB RAM, auto-torn-down) that opt-out (every PR gets one unless disabled) is the right default. A PR that wants to disable the preview (because the changes are docs-only and a preview adds no signal) carries a
no-previewlabel; the webhook-receiver respects it. This keeps the default pro-evidence.
Related
- ADR-020 — shared infra vs per-product isolation; this ADR operates inside the shared-infra boundary (one Hetzner box, one CF account) and introduces per-PR application isolation, not per-PR infra isolation
- ADR-053 — frontend observability; the preview-webhook- receiver, the preview registry, and the preview stack (inside each preview) emit structured logs and metrics to the same observability stack as production
- ADR-055 — observability stack container pinning; the preview stack pulls pinned observ stack image tags, same as production
- ADR-059 — project-first IA, project YAML with
repos:list; the name-match rule (§1 of the pattern) iteratesprojects/<slug>.yaml .reposto find sibling branches - ADR-061 — extracted
gyrum-projectslibrary; the preview-up playbook reads project YAML through this library - ADR-065 — persona-aware IA; the
/ops/previewsscreen (Phase C) is scoped to the SRE persona - ADR-067 — playbooks unified primitive; both
preview-upandpreview-downarekind: release_flowplaybooks following ADR-067's front-matter, relations, and retro conventions - ADR-068 — playbook runtime architecture; the
subplaybook,http,ssh, andshellexecutors used here are ADR-068 Phase 4 primitives - ADR-069 — playbook security;
destructive: trueonpreview-downtriggers ADR-069 §6 implicit approval; the webhook secret lives in the ADR-069 vault; scoped tokens per the ADR-069 §3 allow-list pattern - ADR-070 — playbook UI contract;
<PlaybookRunner>is the surface through which Phase A manual invocations run - ADR-071 — product name Weft; the
<product>.gyrum.aisubdomain pattern (weft.gyrum.ai, nowpreview.gyrum.ai) is the same namespacing pattern - ADR-072 — infrastructure as playbooks; this ADR is
a direct consumer of the ADR-072 catalogue (
cloudflare- dns-upsert,cloudflare-tunnel-route-upsert,cloudflare-access-app-upsert,caddy-site-upsert) and names two new destructive siblings (cloudflare-access- app-delete,cloudflare-tunnel-route-delete) as additions to the catalogue - Fleet E2E compose pattern —
distill-gyrum-ai/ e2e-integration/is the docker-compose shape the per-preview compose file generalises (same image pulls, same inter-service wiring, new per-PR namespace on the compose project name, ports, volumes, networks). ADR-075 promotes that same harness to the fleet's cross-product branch-testing service; its Phase 2--branchoverride and the ADR-073 per-PR preview are complementary (CI plane vs Ops plane), and ADR-073 Phase D names ADR-075's Playwright suite as its scenario source when Phase D ships - Dockerfile-per-repo cohort — the in-flight PR set
that ships production Dockerfiles for
buzzy-frontend,buzzy-backend,distill-gyrum-ai, et al is the precondition that makes "preview runs the prod image" meaningful rather than aspirational
Review cadence
Re-review every 90 days (next: 2026-07-22) against:
- Adoption — what fraction of PRs across project-tracked repos have a preview URL posted in their PR description? Target: > 90% within 30 days of Phase B.
- Latency — PR-open → preview-URL-posted. Target: p50 < 90s, p95 < 180s. Regressions get a bug, not an ADR.
- Capacity — queue depth trend. Target: queue depth = 0 more than 95% of working hours. If queue depth > 2 for > 15 minutes on any given week, raise the conversation about the second preview box (§8).
- Teardown hygiene — reaper-invoked teardowns vs. webhook- invoked vs. manual. Target: < 5% of teardowns are reaper- driven (a healthy ratio means most PRs get merged or closed within 72h, which is what review latency should look like anyway).
- Idempotency violations —
preview-upruns that reportchanged: trueon the second run with no code change. Target: zero. Each one is a bug to file against the relevant sub-playbook. - Access policy drift — occasions on which someone edits the preview's Access app in the CF UI. Each is a retro item. Drift > 1/month → the UI-editing affordance is wrong and we address it.
- Phase C / Phase D due? Are the named horizons now overdue, under-pressure, or still fine to defer? Each cadence review answers this deliberately, not implicitly.
Stale flag on the Owner dashboard once last_reviewed on
this ADR passes 90 days.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one