ADR-071 — Consolidate e2e workflows: docker-e2e becomes the canonical Playwright gate; delete e2e.yml
Status · Accepted Date · 2026-05-30 Closes into ·
.github/workflows/docker-e2e.yml(modifications),.github/workflows/preview.yml(workflow_run change), removal of.github/workflows/e2e.ymlRelated ADRs · ADR-014 (CI pipeline), ADR-015 (Playwright e2e + adapter-static), ADR-066 (docker-e2e — supersedes its trigger surface + relationship sections), ADR-070 (docs/app deploy split) Closes follow-ups · perf-umbrella #95 W7 loose-thread closeout
Context
When docker-e2e.yml shipped (ADR-066, May 22), it was designed to coexist alongside e2e.yml:
e2e.ymlran Playwright againstvite preview— protecting the GitHub Pages deploy target (SvelteKit's Node preview server is the closest local approximation to how GH Pages serves the bundle).docker-e2e.ymlran Playwright against the docker-compose nginx stack — protecting the eventual VPS deploy target (RFC-024).
ADR-066 was explicit about why two separate workflows: cost discipline (Svelte-only PRs shouldn't pay the docker build tax), failure attribution clarity (a red docker-e2e ≡ a docker-shape regression, not a Svelte regression), and minimum-duplication of test work (docker-e2e was scoped desktop-only because the nginx contract is viewport-independent; mobile-chromium stayed in e2e.yml).
Two things have changed since.
Change 1 — W7 sharding broke ADR-066's "desktop-only" invariant
When the docker-e2e mobile leg started touching the 30-min cap, W7 (#277) sharded mobile-chromium into 2 parallel legs to bring per-leg runtime back under control:
matrix:
include:
- project: desktop-chromium
shard: '1/1'
- project: mobile-chromium
shard: '1/2'
- project: mobile-chromium
shard: '2/2'This solved the timeout but silently violated ADR-066's anti-duplication argument: mobile-chromium now runs in both workflows. Same specs, same viewport, same browser engine — once against vite preview, once against docker-nginx. The "mobile stays in e2e.yml; docker-e2e is desktop-only to avoid redundant runner cost" balance the ADR struck was already gone before this ADR was filed.
Change 2 — deployment-topology reframe
ADR-066 implicitly assumed two equally-important deploy targets: GH Pages and the future VPS. In that frame, two e2e workflows protecting two targets was the right shape.
The current frame is different: GH Pages is a staging area on the path to VPS, serving the same users. VPS is the canonical prod target; GH Pages is the transitional fallback. In that frame, only the VPS-shape gate (docker-e2e) protects real prod. The e2e.yml workflow tests vite preview, which:
- Isn't a deploy target (it's a Node preview server SvelteKit ships for local development).
- Doesn't match GH Pages serving either (different MIME handling, different SPA fallback mechanism — vite preview uses SvelteKit's history-API-aware server; GH Pages uses the
404.htmlredirect trick from ADR-014). - Was always a proxy for GH Pages testing, never a direct test of it.
So e2e.yml was protecting a deploy target nobody actually deploys to, using a serving stack nobody actually serves with, and duplicating mobile-chromium coverage that docker-e2e.yml was already providing post-W7.
Decision
Delete .github/workflows/e2e.yml. Promote .github/workflows/docker-e2e.yml to the canonical Playwright gate. The PR cost-discipline + failure-attribution arguments from ADR-066 still apply, but the relationship between the two workflows the ADR specified is no longer needed.
Trigger surface (new)
docker-e2e.yml triggers update to absorb e2e.yml's role:
on:
# Chained after CI (was: direct push:main + path-gated PR + dispatch).
# Matches the workflow_run pattern e2e.yml used to use — a cheap CI
# failure short-circuits the expensive docker build + Playwright run.
workflow_run:
workflows: ['CI']
types: [completed]
branches: [main]
workflow_dispatch:
# External-state drift catch — moved from e2e.yml. Time chosen to land
# before EU devs start the day; off-minute (:07) dodges GitHub
# Actions' top-of-hour scheduler contention.
schedule:
- cron: '7 5 * * *'PR trigger drops entirely. Per the current frame, PRs run CI only. The full Playwright surface runs on main where it's both required (deploy gate) and cheap-amortised (rare merges vs frequent PR pushes).
preview.yml retarget
preview.yml was gated on workflow_run: workflows: ['e2e']. Updates to gate on docker-e2e instead:
on:
workflow_run:
workflows: ['docker-e2e']
types: [completed]
branches: [main]Same conclusion == 'success' guard pattern; same shared gh-pages-publish concurrency group from ADR-070. The GH Pages app+docs deploy now rides the docker-e2e green signal instead of the e2e green signal. Operationally indistinguishable — same Playwright specs, same pass/fail outcome, just routed through one workflow instead of two.
What this supersedes in ADR-066
ADR-066's design intent (a CI gate that asserts the nginx-serving contract) stands. What this ADR supersedes:
- The "Trigger surface" section (
push: branches: [main]+ path-gatedpull_request:) is replaced by workflow_run-after-CI + cron + dispatch (no PR trigger). - The "Why desktop-chromium only" section is no longer in effect — W7 added mobile sharding, and docker-e2e is now the only e2e workflow, so it must cover mobile.
- The "Relationship to existing workflows" three-workflow partition is replaced by a two-workflow partition:
ci.yml(cheap gate) +docker-e2e.yml(heavy gate). - The "Branch protection" path-conditional required-for-merge configuration is moot — PRs don't run docker-e2e at all under the new shape.
The rest of ADR-066 (decision to use the docker-compose stack as the validation target, PLAYWRIGHT_BASE_URL env-var override, playwright.config.ts webServer short-circuit, capture-logs-on-failure, GHCR publish on green main) continues to apply unchanged.
Rationale
Why now, not earlier
The ADR-066 design was correct for May 22's context: GH Pages was the live production target, the docker stack was a future bet, both needed their own e2e gates. Two changes (W7 sharding silently breaking ADR-066's no-mobile-duplication invariant + the deployment-topology reframe positioning VPS as canonical and GH Pages as transitional) moved the right answer here.
This ADR isn't reversing ADR-066; it's recognising that two changes that both happened independently have moved us past it.
Why workflow_run-after-CI on the new docker-e2e
Today docker-e2e fires on push: branches: [main] directly — parallel with CI rather than chained. That made sense when docker-e2e was the secondary gate and CI was the primary. Now that docker-e2e is the gate, the workflow_run pattern e2e.yml pioneered applies: cheap typecheck/lint/unit-test failure short-circuits the expensive docker build + Playwright run. Saves ~15-25 min when CI fails on main; adds ~2-3 min of latency when CI succeeds (which is the common case but matters less because main-push wall-clock is less critical than PR wall-clock).
Why no PR trigger
Per the current frame, the cost-amortisation argument for PR gates is different than ADR-066 assumed:
- ADR-066: required-for-merge on PRs that touch docker paths, advisory on everything else. Reasoning: catch docker regressions at PR time so they don't land on main.
- Now: PR feedback should be fast (~3 min CI), main is where the heavy gate runs. Any regression that escapes CI gets caught on the next
workflow_run-chained docker-e2e and blockspreview.ymlfrom deploying.
The "regression hits main but doesn't deploy" failure mode is acceptable — the deploy gate is what actually matters, and it stays in place via preview.yml's workflow_run dependency.
Why keep the nightly cron
ADR-066 didn't carry a cron because external-state-drift wasn't its concern. e2e.yml had one (catching CDN outages, Wikimedia URL drift). Moving the cron to docker-e2e.yml preserves that protection without adding new infrastructure.
Alternatives considered
- Keep both workflows, fix the W7 duplication by dropping mobile from docker-e2e and accepting 30-min mobile-only docker-e2e runtime. Rejected: returns to the desktop-only/mobile-only split ADR-066 originally chose, but adds back the runtime cliff W7 was solving. And the deployment-topology reframe still leaves e2e.yml protecting a deploy target nobody deploys to.
- Keep both workflows, drop the e2e.yml mobile coverage to dedup with docker-e2e. Rejected: leaves e2e.yml as a desktop-only workflow protecting
vite preview, which is even less useful than today. The remaining problem (vite preview ≠ a real deploy target) doesn't go away. - Delete docker-e2e instead, keep e2e.yml. Rejected: explicitly contradicts ADR-066's central observation that nginx contract bugs (Cache-Control, CSP, brotli, SPA fallback via
try_files) only surface under nginx. e2e.yml againstvite previewcannot catch them. - Run docker-e2e on PRs path-gated to docker paths. Rejected: per the current frame, PRs run CI only. Path-gated PR triggers add operational complexity (branch-protection path-conditional required-for-merge is a repo-settings concern documented in ADR-066) for marginal value. If a docker-touching PR is going to land on main and trigger workflow_run-chained docker-e2e anyway, the regression is caught one step later but still before deploy.
Consequences
Positive:
- One canonical Playwright gate instead of two. Mobile-chromium runs once, not twice. Roughly halves the per-push runner-minute cost on main pushes.
- preview.yml deploy gate is consolidated — when it fires, it's because the workflow that protects prod (docker-e2e) passed. Cleaner mental model.
- PR feedback loop is uncoupled from e2e cost. ~3 min CI gate per PR; main pays the ~17-25 min docker-e2e cost.
- e2e.yml's nightly external-state-drift catch is preserved on docker-e2e.yml.
- ADR-066's anti-duplication invariant is restored (mobile-chromium runs once again, just in the surviving workflow).
Negative:
- The
vite previewserving path is no longer tested in CI at all. Counter: it was never a deploy target, GH Pages serving differs from it, and the Playwright specs themselves don't care which server they hit. If a vite-preview-specific bug ever needs catching, localnpm run test:e2estill exercises the path. - One more documentation update sweep: README badge, AGENTS.md workflow listing, this ADR.
- ADR-066 partial supersession adds historical-context complexity for future readers. Mitigated by the explicit list above of what stands vs what's superseded.
Decision status
Accepted on 2026-05-30. Implementation:
.github/workflows/e2e.ymldeleted..github/workflows/docker-e2e.yml: triggers swapped from push:main + path-gated PR + dispatch to workflow_run-after-CI + dispatch + nightly cron (cron value7 5 * * *lifted from e2e.yml)..github/workflows/preview.yml:workflow_run: workflows: ['e2e']→workflow_run: workflows: ['docker-e2e'].README.md: badge URL points at docker-e2e.yml instead of e2e.yml.AGENTS.md: workflow listing dropse2e.yml; chain referenceci.yml → e2e.yml → preview.ymlupdates toci.yml → docker-e2e.yml → preview.yml.
ADR-066's "Trigger surface" + "Why desktop-chromium only" + "Relationship to existing workflows" + "Branch protection" sections are marked superseded by this ADR; ADR-066's core decision (docker-compose stack as the e2e validation target, Playwright env-var override, webServer short-circuit) continues to apply.