Files
ersatztv/docs/decisions/records/ci/image-build-delegates-the-spa-suite.md
T
timothyandClaude Opus 5 9da0020462
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 10s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 17s
PR Gates / Docs update reminder (pull_request) Successful in 14s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 12s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
review-verdict/h10 Review-verdict: MERGEABLE @ 9da0020 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m53s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m2s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
docs(887): sweep the withdrawal through the record — it still described the withdrawn pin
Round 9 returned MERGEABLE with BLOCKER and HIGH empty. Every remaining item was a
sentence, and every one erred by UNDERSTATING the guard — which is the safe direction and
still worth fixing, because the decision record is what CLAUDE.md routes convention
lookups to.

The record's `rule:` still listed "`web/vite.config.ts`'s `test:` block" among the pinned
things — the very mechanism the previous commit withdrew — and named only `vitest.config.*`
as the outranking family, omitting `vite.config.js`/`.mjs`, which is the MEASURED attack
from round 7 (a `web/vite.config.js` ran the suite in the gitless stage with 1411 tests
green). That family went short in round 7 and again in round 8. This is
`enumerate-CLAUSES-to-close-a-sweep`: the survivors were phrased in a different category
(WHAT is pinned) from the retracted claim (HOW it is extracted), so sweeping for the
retracted words missed them.

Also: "any edit to this file reddens, including a comment" was an absolute and is
refutable — a reindent, added blank lines, tabs, and a form feed all stay green, because
`_normalise_lines` collapses whitespace. Restated as what is actually true (a line's TOKEN
sequence, a comment's words included) plus the reason the tolerance is currently inert:
this file has no template literal and no ASI-sensitive token outside a comment. And a YAML
single-quote escape had leaked from the frontmatter into the markdown BODY, where `''`
renders literally.

No code change; the guard is unchanged and still 73/0.

Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
2026-08-30 17:14:22 +02:00

11 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
ci.image-build-delegates-the-spa-suite 2026-08-30 — the image build builds the SPA and does not test it (#887) active 2026-08-30 none none `docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step's `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step's own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`'s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts` PINNED WHOLE — it decides what the suite collects and what `vite build` loads, and pinning a BLOCK of it was defeated seven measured ways across three rounds, so the partial match was withdrawn rather than respelled an eighth time; and the ABSENCE of any config file that outranks it — `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite build`, whose `DEFAULT_CONFIG_FILES` lists them BEFORE `.ts`. Pinning one file is worthless while a second can outrank it, and that family went short twice. The gating job's `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. image build fails on main only · every image build fails · latest not republished · release cut fails at the image build · gitless build STAGE not context · node:22-bookworm-slim ships no git · virtual:etv-tracked-source-files could not read the git index · hand-maintained exclude list · exclusion list is the underived population · suite members needing the git binary vs a checkout · COPY .git would not help · needs edge is the only gate · unfiltered gating run · paths: `docker/Dockerfile`, `.gitea/workflows/docker-build.yml`, `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `web/vite-plugins/trackedSourceFiles.ts` · issues: #887, #883, #819, #806, #420 Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. A population decides what is CHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one goes loudly red. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step's `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 73 mutants, 0 missed (development battery).

The web-build stage is gitless twice over. It copies only web/ and design-system/, so it holds no .git. That is a property of the STAGE, not of the build context — the context is the repository root (context: .) and .dockerignore does not exclude .git, so the directory is available to be copied and simply is not; stating it the other way invites a reader to check, find .git in the context, and conclude the whole note is stale. And node:22-bookworm-slim ships no git binary. Members of the SPA suite need one or the other: pageSizeCallSites.guard.test.ts and completeAnnotations.guard.test.ts derive their file population from git ls-files and refuse to fall back to a directory walk (testing.guard-derives-population-from-source), and trackedSourceFiles.realgit.test.ts builds a real temporary repository. Because the two prerequisites OVERLAP rather than divide, adding a COPY .git fixes neither: the binary is still missing.

So running the suite there at all costs a list of the members that cannot run, maintained by hand beside a suite that grows. That list went stale the first time a guard was added without updating it, and the failure it produced was invisible where it could have been cheap and fatal where it was not: green on every PR, red on main and on the release tag.

Why not the alternatives. Each was considered and rejected on its own evidence, not by analogy:

  • Add a third --exclude. Re-arms the same trap for the next guard, which is what the issue was filed to prevent.
  • Derive the exclusions from source. Buildable, but it requires a predicate for "needs git" over spec text — two unrelated shapes today (a virtual-module import, a git spawn) and no reason to think that stays two. A string predicate over source is the wrong instrument for a set whose members are defined by what they DO at runtime.
  • Give the container git (apt-get install git plus a real .git). It works, and it costs a cache-busting COPY of VCS metadata on every commit, so the whole lint/typecheck/test/build layer re-runs for changes that do not touch web/. Synthesising a repository in the image instead — git init && git add -A — is worse than it looks: the resulting index is a filesystem walk with extra steps, which is precisely the population the guards refuse.

What the removal gives up, enumerated rather than waved through (process.enumerate-workaround-behaviors-before-deleting):

  1. Blocking a publish on a red suite — retained, by the needs: edge. Two skips exist inside test. docs_only also gates Build and push, so that arm ships nothing — ASSERTED, by test_the_DOCS_ONLY_arm_cannot_publish_an_image, after cold review pointed out it had been stated as fact with nothing checking it. The #420 revalidate skip fires only on a tree byte-identical to a head that already carried a green combined status; that arm is a DEPENDENCY on scripts/ci-detect-already-validated.sh rather than something asserted here, and that script is graded MUTATION: NONE. Said plainly because #887 is what made the second arm load-bearing: before it, the in-image run covered that case.
  2. Running the suite under node:22-bookworm-slim specificallylost, and this is the real cost. It is small because the suite is jsdom and pure JS, and because the artifact the image actually ships is the output of vite build, which still runs there: a test that passes in the CI toolchain image and fails in bookworm-slim would be telling us about the test environment, not about the image.
  3. docker build running the suite for a developer locally — lost; npm test -- --run is the direct route and is what docs/testing.md documents.

The guard's own mechanism was replaced once, and the reason belongs here rather than only in the commit log, because the next person to widen it needs it. Three versions asked whether a given command runs the suite and whether its failure can be swallowed. That predicate was wrong nine times across three cold-review rounds — executed heredocs treated as data, # truncating a command mid-word (including the live ${#reports[@]} idiom), compound punctuation welding commands, npm t and ./node_modules/.bin/vitest and timeout/su -c wrappers unrecognised, true || npm test counted as a run it never performs, and continue-on-error: ${{ … }} slipping a two-literal check. Twice, a clause added to remove a FALSE RED opened a FALSE GREEN on the guard's headline assertion. The mechanism was withdrawn rather than patched a tenth time: the risky command lines are now compared against a pin, so no spelling has to be recognised in order to be rejected. The cost is real and is the point — a legitimate edit to those lines reddens the guard and its author updates the pin deliberately.

A fourth cold review then attacked the pin itself and found the honest limit: the replacement removes the SPELLING problem outright, but the same pinned text can be made to mean something else, and two mutants re-armed this very defect through web/package.jsonRUN npm run build executes whatever that file says — with every pin still matching. A fifth round then found the same mistake inside the fix: the package.json check SELECTED on the literal vitest where a PIN was available, and four one-line edits that never spell it — npm run test, npm t, and the prebuild/preinstall lifecycle hooks — each re-armed the defect with every other pin matching. The whole script map is pinned now, and so is web/vite.config.ts — WHOLE, after three further rounds each defeated one spelling of a partial match of it, and two more routes bypassed the marker entirely because defineConfig is the identity function and a later spread replaces what was matched.

The general lesson is worth more than any of the fixes: replacing a predicate with a pin moves the problem from "can the checker recognise this?" to "is the checked text still the whole story?". The second question has a short, workable answer where the first did not — but it is still a question, the route list has been wrong at every count so far, and the honest form of that is a running total with its history attached rather than a closure claim.

lint and typecheck stay in the stage. They are gitless-safe with no member that is not, so they carry no list and no trap. Whether the image build should run them at all is a separate question this does not answer, and leaving them is not an argument that it should.