Skip to content

ADR-066 — Docker-stack e2e CI workflow — Playwright against the running compose stack as the VPS-readiness gate

Status · Accepted (Trigger surface, "desktop-chromium only", Relationship to existing workflows, and Branch protection sections superseded by ADR-071) Date · 2026-05-22 Closes into · RFC-024 §7 Related ADRs · ADR-015 (Playwright e2e + adapter-static), ADR-063 (web container), ADR-064 (pipeline runner), ADR-065 (live data volume), ADR-071 (consolidation of e2e + docker-e2e — docker-e2e becomes the canonical Playwright gate; e2e.yml deleted) Closes follow-ups · "Stack test workflow where we stand up through docker and then run some sort of Playwright tests against it to validate all is good and ready for further deploys" — RFC-024's scoping ask

Context

The current Playwright suite (tests/e2e/*.spec.ts) runs against vite preview via e2e.yml. That exercises the adapter-static build artefact, but it doesn't exercise the production-shape serving path: nginx + the cache headers from ops/docker/nginx.conf + the named-volume seed contract + the docker network plumbing. Bugs in any of those (an nginx misconfig, a Cache-Control regression, a volume mount that doesn't expose /data/*.json properly) would slip past e2e.yml and surface only at deploy.

RFC-024's whole point is to make the docker stack the eventual deploy artefact. To trust it, we need CI that stands up the same compose file and asserts the existing test contract against the live containers. Without this gate, the VPS RFC has nothing to certify against; every push to main could quietly break the docker artefact while e2e.yml stays green.

The decision is the shape of this CI workflow: which jobs run when, what URL Playwright points at, how it relates to e2e.yml, and whether it's required-for-merge.

Decision

A new GitHub Actions workflow .github/workflows/docker-e2e.yml, runs on every push to main plus PRs that touch docker/compose surface paths plus workflow_dispatch. Standalone — not merged into ci.yml or e2e.yml. Builds both compose images, brings up web (not pipeline-runner), waits for healthcheck via curl polling, runs the existing Playwright suite with PLAYWRIGHT_BASE_URL=http://localhost:8080, captures container logs on failure. Required-for-merge gated only on PRs that touch docker paths; advisory on every other push.

Trigger surface

yaml
on:
  push:        { branches: [main] }
  pull_request:
    paths:
      - 'Dockerfile'
      - 'docker-compose.yml'
      - 'ops/docker/**'
      - 'package.json'
      - 'package-lock.json'
      - '.github/workflows/docker-e2e.yml'
  workflow_dispatch:

paths:-gated on PRs to keep cost down — a touch-only-the-Svelte PR doesn't need to pay the ~4 min docker build. On push to main it runs unconditionally so trunk health is always known.

Workflow body (excerpt)

yaml
jobs:
  docker-e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - name: Build images
        run: docker compose build
      - name: Bring up web
        run: docker compose up -d web
      - name: Wait for web
        run: |
          for i in $(seq 1 60); do
            if curl -sf http://localhost:8080/ >/dev/null; then exit 0; fi
            sleep 2
          done
          docker compose logs web
          exit 1
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Playwright against docker web
        env:
          PLAYWRIGHT_BASE_URL: http://localhost:8080
        run: npx playwright test --project=desktop-chromium
      - name: Capture logs on failure
        if: failure()
        run: docker compose logs web
      - uses: actions/upload-artifact@v4
        if: failure()
        with: { name: docker-e2e-report, path: playwright-report/ }

Playwright config short-circuit

playwright.config.ts already lifts use.baseURL from process.env.PLAYWRIGHT_BASE_URL when present, but it also unconditionally tries to start its own webServer: { command: 'npx vite preview …' } block. The change for this ADR:

ts
// playwright.config.ts
export default defineConfig({
  use: { baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173', … },
  // Skip starting a webServer when an external base URL is provided
  // (docker-e2e CI provides one). Local dev + the existing e2e.yml
  // path keep the vite preview spawn.
  webServer: process.env.PLAYWRIGHT_BASE_URL ? undefined : {
    command: 'npx vite preview --port 4173 --host 127.0.0.1',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },

});

This one-line short-circuit is the only Playwright-side change. The 90 e2e specs are otherwise unchanged — they don't care whether the URL they hit is vite preview on 4173 or docker nginx on 8080.

Why desktop-chromium only (not mobile-chromium)?

Mobile-chromium tests target the same code path with a different viewport. The properties that can differ between vite preview and docker-nginx are server-side: cache headers, gzip behaviour, MIME types, SPA fallback. None of those are viewport-dependent. Running mobile-chromium too would double CI time for zero additional coverage of what this workflow is supposed to validate. Mobile-chromium continues to run in e2e.yml; this workflow is the nginx contract gate.

Relationship to existing workflows

  • ci.yml — typecheck + lint + unit tests + validate-data + build. Mirrors npm run preflight. Unchanged.
  • e2e.yml — Playwright vs vite preview. Desktop + mobile. Unchanged.
  • docker-e2e.yml (this ADR) — Playwright vs docker-nginx. Desktop only. New.

The three workflows partition the test surface: ci.yml for build-time validity, e2e.yml for SvelteKit + Vite artefact, docker-e2e.yml for production-shape nginx serving. They are independent — a green e2e.yml + a red docker-e2e.yml means the SvelteKit code is correct but the docker stack regressed (e.g. someone edited nginx.conf wrongly).

Branch protection

  • ci.yml and e2e.yml: required-for-merge on every PR (existing posture).
  • docker-e2e.yml: required-for-merge only on PRs whose paths: filter triggers it — i.e. PRs that touch Dockerfile, docker-compose.yml, ops/docker/**, package-lock changes, or this workflow file itself. PRs that don't touch those don't pay the docker-build cost and aren't blocked by the gate.

This branch-protection nuance is configured in repo settings (Branches → main → required status checks → "Require status checks only if these paths changed" — GitHub's path-conditional checks). Documented in docs/guides/docker-stack.md.

Future-mode

When the VPS RFC lands, docker-e2e.yml is the workflow that gets extended to also assert against a production override (compose.prod.yml) — same e2e suite, different compose layer. That extension lives in the VPS RFC; the base workflow here doesn't need to anticipate it.

Rationale

A separate workflow rather than extending e2e.yml:

  • Cost discipline. Docker build is ~3-4 min cold, ~1 min warm. Adding it to e2e.yml for every PR doubles the per-PR e2e CI cost. As a path-gated separate workflow, a Svelte-only PR doesn't pay.
  • Failure-attribution clarity. A failure in docker-e2e.yml tells the operator immediately: "this is a docker-stack regression, not a Svelte regression." Merged into e2e.yml, the same failure would surface as "Playwright failed" with the operator left to dig.
  • Branch-protection precision. Path-conditional required-for-merge is easier to express with a separate workflow than with a merged one.

PLAYWRIGHT_BASE_URL env-var override is the established Playwright pattern for "run the same tests against a different URL"; we don't have to fork the test suite or write a playwright.docker.config.ts. The one-line webServer: short-circuit is the smallest change that achieves what we need.

Desktop-chromium only (no mobile) because the workflow validates the nginx-serving contract, which is viewport-independent. Mobile-chromium remains covered by e2e.yml. We'd double cost for no validation gain.

Required-for-merge only when paths trigger is the right balance. Always-required blocks PRs that have nothing to do with docker on infrastructure they didn't touch; never-required lets docker regressions slip into main with no human acknowledgement. Path-gated mandatory is the standard pattern for "this gate exists if and only if the surface it gates was edited."

Alternatives considered

  • Add docker-e2e steps to e2e.yml. Cheaper to set up (no new workflow file). But: doubles per-PR cost on every PR; merges failure attribution; can't path-gate without splitting the workflow internally. Rejected; the cost discipline matters and the separate-workflow pattern is clearer.
  • Required-for-merge on every PR. Maximally strict. Wastes ~4 min of runner time on every Svelte-only PR. The path-gated variant gives the same protection without the tax. Rejected.
  • Don't gate it at all (advisory only). Allows docker regressions to land silently. Defeats the point of having the workflow. Rejected.
  • Test against docker compose run --rm pipeline-runner too (asserting pipeline scripts execute correctly inside the container). Tempting but: the existing ci.yml already runs the same scripts on a GH Actions runner; the docker-pipeline image is mechanically the same Node + tsx + gdal stack; doubling that coverage in two workflows isn't worth the runner time. The first real divergence (gdal version mismatch on a specific platform) would justify pipeline-side e2e — until then, the web-side contract is the surface that actually changes with this RFC.
  • Run both desktop + mobile. ~2x runner time. Adds no validation we don't already have via e2e.yml's mobile-chromium project. Rejected as redundant.
  • Push images to GHCR as part of this workflow, gate the e2e on the published image. Pulls the GHCR-publish concern into this RFC. Rejected — RFC-024 §10 explicitly defers GHCR. Once that lands, this workflow gets extended to optionally pull-by-tag instead of always-rebuild.

Consequences

Positive:

  • The docker stack has a green-when-it-works contract that travels with every PR that touches it. Regressions surface in CI, not at deploy.
  • The future VPS RFC has a credible "this is what runs in CI" anchor — the e2e gate proves the stack works in a runner; extending it to the prod overlay is incremental.
  • Failure attribution is clean: a red docker-e2e.yml always means "something docker-shaped regressed."
  • The path-gated trigger keeps the cost honest. A typical week of Svelte PRs doesn't pay the ~4 min docker tax.

Negative:

  • One more workflow to maintain. ~80 LOC of YAML. Routine.
  • The playwright.config.ts short-circuit is a small new branch; needs a unit-of-thought when bumping Playwright versions. Documented inline.
  • Mobile-chromium against docker-nginx isn't covered. Acceptable trade-off per §desktop-only above; mobile coverage exists in e2e.yml. If we later discover a viewport-dependent docker bug (we won't, but if), add a second job.
  • The branch-protection path-gating requires repo-settings configuration outside this PR. Documented in docs/guides/docker-stack.md; a checklist item in the slice-1 PR description.

Decision status

Accepted on 2026-05-22. Shipped in commit 924fee5ed (Slice 2 of RFC-024). .github/workflows/docker-e2e.yml lives at the path documented above; playwright.config.ts:30-31 adds the PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:4173' fallback on use.baseURL; playwright.config.ts:49-60 makes the webServer block conditional on the same env var. Both paths smoke-tested locally on 2026-05-22: with PLAYWRIGHT_BASE_URL=http://localhost:8080 Playwright hits the live docker nginx container; without it, Playwright spawns its own vite preview as before. The launches-banner.spec.ts:13:3 test was used as the canary for both paths and passed in each.

Branch-protection configuration (path-conditional required-for-merge) is a repo-settings task and is not included in the slice-2 PR. A first PR that touches the docker surface will trigger the gate; if the gate flakes there before bedding in, we'll iterate before configuring it as required. Documented in docs/guides/docker-stack.md and tracked as a follow-up checklist item.

Orrery — architecture documentation · MIT · No tracking