Skip to content

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.yml Related 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.yml ran Playwright against vite 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.yml ran 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:

yaml
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:

  1. Isn't a deploy target (it's a Node preview server SvelteKit ships for local development).
  2. 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.html redirect trick from ADR-014).
  3. 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:

yaml
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:

yaml
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-gated pull_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 blocks preview.yml from 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 against vite preview cannot 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 preview serving 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, local npm run test:e2e still 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.yml deleted.
  • .github/workflows/docker-e2e.yml: triggers swapped from push:main + path-gated PR + dispatch to workflow_run-after-CI + dispatch + nightly cron (cron value 7 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 drops e2e.yml; chain reference ci.yml → e2e.yml → preview.yml updates to ci.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.

Orrery — architecture documentation · MIT · No tracking