Files
ersatztv/docs/decisions/records/ci/image-build-delegates-the-spa-suite.md
T
timothyandClaude Opus 5 3ec73f3769 fix(887): a pin assumes it is pinning the thing that still DECIDES
Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:

  * `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
    commit, found by probing vitest rather than reading about it.
  * A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
    of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
    Exactly one is now required — the same assertion this file already made about the
    gating step's NAME, for the same reason, not carried across.
  * A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
    a SECOND workflow publishing this Dockerfile while needing its own unrelated job
    called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
    this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
    classify any new workflow — so the hole is "the guard is blind", not "silent". The
    forced review asks about remote state, not about whether the image is gated, so the
    one-line fix stands.)
  * A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
    pinned; the plugin BODIES are a stated residual, mitigated because
    `trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
    LOAD-BEARING for the image build rather than leaving as an optimisation note.

THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.

Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.

Battery 61 -> 64, 0 missed.

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

9.9 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`'s `test:` block, which decides what the suite collects, and the ABSENCE of any `vitest.config.*` — vitest resolves its own config in preference to `vite.config.*`, so pinning one file is worthless while a second can outrank it. 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. ELEVEN routes is a running total, not a proof — written as five, six, seven and eight, each wrong when checked. 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. 64 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 and neither leaves an image unvalidated: docs_only also gates Build and push, so that arm ships nothing, and the #420 revalidate skip fires only on a tree byte-identical to a head that already carried a green combined status.
  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 vite.config.ts''s test: block.

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.