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
This commit is contained in:
@@ -54,7 +54,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
|||||||
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class. Gitea injects `GITEA_TOKEN` into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge `review-verdict/h10`; since #748 (2026-08-27) all six workflows here declare `permissions:` — five `code: read`, and `review-verdict.yml`'s gate job `code: write`, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job's JOB-LEVEL form is the one measured; see `ci.actions-credential-scoping`). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch — `RENOVATE_TOKEN` is in the same secret store and is referenced by `renovate.yml`, one of the five holding `code: read`. `ci-image.yml` was the last holdout — its unfiltered `push:` made it the worst-placed of the six — and #744 closed that trigger route (`ci.toolchain-image-publish-is-a-dispatch`) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. And a collaborator's own API token still can, since branch protection binds the context and not its issuer. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (an ALLOW-LISTED user credential's forgery carries a matching `creator` and is inherited as a verdict; an Actions job's carries `creator: null` and is re-derived, as since #742 is every account outside `H10_REVIEWERS` — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared `code: write` is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. A collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
|
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class. Gitea injects `GITEA_TOKEN` into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge `review-verdict/h10`; since #748 (2026-08-27) all six workflows here declare `permissions:` — five `code: read`, and `review-verdict.yml`'s gate job `code: write`, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job's JOB-LEVEL form is the one measured; see `ci.actions-credential-scoping`). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch — `RENOVATE_TOKEN` is in the same secret store and is referenced by `renovate.yml`, one of the five holding `code: read`. `ci-image.yml` was the last holdout — its unfiltered `push:` made it the worst-placed of the six — and #744 closed that trigger route (`ci.toolchain-image-publish-is-a-dispatch`) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. And a collaborator's own API token still can, since branch protection binds the context and not its issuer. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (an ALLOW-LISTED user credential's forgery carries a matching `creator` and is inherited as a verdict; an Actions job's carries `creator: null` and is re-derived, as since #742 is every account outside `H10_REVIEWERS` — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared `code: write` is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. A collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
|
||||||
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
|
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
|
||||||
| `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) |
|
| `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) |
|
||||||
| `ci.image-build-delegates-the-spa-suite` | `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. Only an `ENV` rewriting `PATH` is unmodelled. EIGHT routes is a running total, not a proof — it was written as five, then six, then seven, and each was wrong when checked. 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`. | 2026-08-30 | [link](records/ci/image-build-delegates-the-spa-suite.md) |
|
| `ci.image-build-delegates-the-spa-suite` | `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`. | 2026-08-30 | [link](records/ci/image-build-delegates-the-spa-suite.md) |
|
||||||
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
|
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
|
||||||
| `ci.jq-version-contract` | Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. `scripts/jq-preflight.sh` (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; `scripts/jq-preflight.sh --expect 1.6` additionally pins and fails loudly, but ONLY in the `script-tests` job. `review-verdict.yml` never pins — it writes the branch-protection-required `review-verdict/h10` status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. | 2026-07-26 | [link](records/ci/jq-version-contract.md) |
|
| `ci.jq-version-contract` | Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. `scripts/jq-preflight.sh` (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; `scripts/jq-preflight.sh --expect 1.6` additionally pins and fails loudly, but ONLY in the `script-tests` job. `review-verdict.yml` never pins — it writes the branch-protection-required `review-verdict/h10` status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. | 2026-07-26 | [link](records/ci/jq-version-contract.md) |
|
||||||
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](records/ci/killed-job-triage.md) |
|
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](records/ci/killed-job-triage.md) |
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ status: active
|
|||||||
since: '2026-08-30'
|
since: '2026-08-30'
|
||||||
supersedes: none
|
supersedes: none
|
||||||
superseded-by: none
|
superseded-by: none
|
||||||
rule: '`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. Only an `ENV` rewriting `PATH` is unmodelled. EIGHT routes is a running total, not a proof — it was written as five, then six, then seven, and each was wrong when checked. 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`.'
|
rule: '`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`.'
|
||||||
signals: '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'
|
signals: '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'
|
||||||
mechanics: '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. 61 mutants, 0 missed.'
|
mechanics: '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
|
The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -66,6 +66,14 @@ So: the pin removes the SPELLING problem outright and reduces the MEANING proble
|
|||||||
be worked through — a much better place than a predicate that was wrong nine times. It is not
|
be worked through — a much better place than a predicate that was wrong nine times. It is not
|
||||||
"cannot be wrong", and the list's own history says so.
|
"cannot be wrong", and the list's own history says so.
|
||||||
|
|
||||||
|
THE SHAPE THOSE ROUTES SHARE, which is the thing to check when adding a pin rather than a list to
|
||||||
|
memorise: **a pin assumes it is pinning the artifact that still DECIDES.** Every instance found so
|
||||||
|
far is authority moving somewhere the pin is not looking — to another FILE (`vitest.config.*`
|
||||||
|
outranks `vite.config.ts`), to another OCCURRENCE in the same file (a decoy first `test: {`), to
|
||||||
|
another WORKFLOW (a `needs:` edge naming a job called `test` that is not this one), or to a HOOK the
|
||||||
|
pinned command invokes (a vite plugin, an npm lifecycle script). Ask of any new pin: what else could
|
||||||
|
decide this, and would the pin still match?
|
||||||
|
|
||||||
The cost is stated rather than hidden: a legitimate edit to those command lines reddens this guard
|
The cost is stated rather than hidden: a legitimate edit to those command lines reddens this guard
|
||||||
and its author must update the pin. That is the same deliberate review trigger `KNOWN_GATE_*` uses,
|
and its author must update the pin. That is the same deliberate review trigger `KNOWN_GATE_*` uses,
|
||||||
applied to the two places where a silent change publishes an untested image.
|
applied to the two places where a silent change publishes an untested image.
|
||||||
@@ -93,9 +101,12 @@ WHAT IS NOT COVERED, stated rather than implied:
|
|||||||
copy whose source names `web` — and NOT the wider "has the suite available to run", which is
|
copy whose source names `web` — and NOT the wider "has the suite available to run", which is
|
||||||
what an earlier version of this sentence claimed. A stage copy that RENAMES the tree on the way
|
what an earlier version of this sentence claimed. A stage copy that RENAMES the tree on the way
|
||||||
in (`COPY --from=web-build /source/web /elsewhere`) has no `web` segment in its source and is
|
in (`COPY --from=web-build /source/web /elsewhere`) has no `web` segment in its source and is
|
||||||
not recognised — nor is a source that is an ANCESTOR of it (`COPY --from=web-build /source /src`
|
not recognised. THAT CLAUSE WAS WRONG THREE TIMES and is now stated from a measurement rather
|
||||||
brings `/source/web` along without naming it). Both were MEASURED; the previous two attempts at
|
than from memory: `COPY --from=web-build /source/web /elsewhere` IS recognised (only the
|
||||||
this sentence each named a shape that was in fact covered while omitting these. The
|
DESTINATION is renamed, and only sources are read). What is not: a source that is an ANCESTOR of
|
||||||
|
the tree (`COPY --from=web-build /source /src` brings `/source/web` along without naming it) or
|
||||||
|
names it only as `/source/.`. Both measured, and both backstopped by the substring sweep — an
|
||||||
|
ancestor-copy stage running `npm t`, `npx vitest` or a new npm script is caught. The
|
||||||
false-NEGATIVE direction is what drops a stage out of the check, so the predicate is pinned in
|
false-NEGATIVE direction is what drops a stage out of the check, so the predicate is pinned in
|
||||||
both directions by its own test, and the substring sweep below is the backstop for whatever it
|
both directions by its own test, and the substring sweep below is the backstop for whatever it
|
||||||
still misses.
|
still misses.
|
||||||
@@ -213,6 +224,12 @@ VITE_CONFIG = "web/vite.config.ts"
|
|||||||
# image published. Pinning one file is worthless while a second can outrank it, so the absence of
|
# image published. Pinning one file is worthless while a second can outrank it, so the absence of
|
||||||
# every higher-precedence spelling is asserted rather than assumed.
|
# every higher-precedence spelling is asserted rather than assumed.
|
||||||
VITEST_CONFIG_PREFIXES = ("vitest.config.", "vitest.workspace.", "vitest.projects.")
|
VITEST_CONFIG_PREFIXES = ("vitest.config.", "vitest.workspace.", "vitest.projects.")
|
||||||
|
# A vite plugin runs inside the same process as the suite and can shell out from a build hook, so a
|
||||||
|
# `buildStart()` calling `npm test` re-arms ersatztv#887 from the plugin side with every other pin
|
||||||
|
# matching (measured). The ARRAY is pinned so no plugin joins silently; the plugin BODIES are not,
|
||||||
|
# and that is a stated residual — `trackedSourceFiles.ts` is deliberately LAZY for this exact
|
||||||
|
# reason, which its own comment now marks as load-bearing for the image build.
|
||||||
|
PINNED_VITE_PLUGINS = "plugins: [react(), trackedSourceFilesPlugin()],"
|
||||||
PINNED_VITE_TEST_BLOCK = """test: {
|
PINNED_VITE_TEST_BLOCK = """test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
environmentOptions: {
|
environmentOptions: {
|
||||||
@@ -245,6 +262,16 @@ def _vite_test_block() -> list[str]:
|
|||||||
explained without the explanation being pinned.
|
explained without the explanation being pinned.
|
||||||
"""
|
"""
|
||||||
text = _read(VITE_CONFIG)
|
text = _read(VITE_CONFIG)
|
||||||
|
occurrences = text.count("test: {")
|
||||||
|
# A decoy `const reviewed = { test: {…} }` copied byte-for-byte from the pin, placed ABOVE
|
||||||
|
# `defineConfig`, satisfies a comparison that takes the FIRST match while the real block is
|
||||||
|
# narrowed — measured green. The file already requires exactly ONE step named `Test SPA` for the
|
||||||
|
# same reason; the reasoning had not been applied here.
|
||||||
|
assert occurrences == 1, (
|
||||||
|
f"{VITE_CONFIG} contains {occurrences} `test: {{` blocks, not one. The pin compares the "
|
||||||
|
f"FIRST, so a second is either a decoy that satisfies it while another block decides, or a "
|
||||||
|
f"real second config nobody is checking."
|
||||||
|
)
|
||||||
start = text.index("test: {")
|
start = text.index("test: {")
|
||||||
depth = 0
|
depth = 0
|
||||||
for index in range(start, len(text)):
|
for index in range(start, len(text)):
|
||||||
@@ -560,13 +587,20 @@ def test_every_IMAGE_PUBLISHING_job_is_gated_on_the_job_that_runs_the_suite() ->
|
|||||||
if not any(stage_rel == dockerfile for stage_rel, _, _ in spa_carrying_stages()):
|
if not any(stage_rel == dockerfile for stage_rel, _, _ in spa_carrying_stages()):
|
||||||
continue
|
continue
|
||||||
gates = _transitive_needs(job_id, jobs)
|
gates = _transitive_needs(job_id, jobs)
|
||||||
if GATING_JOB not in gates:
|
# `needs:` names a job in the SAME workflow, so the edge only reaches the pinned step when
|
||||||
|
# the publishing job lives in `GATING_WORKFLOW`. Matching a bare job id anywhere was a false
|
||||||
|
# green: a second workflow publishing this Dockerfile while `needs:`-ing its OWN unrelated
|
||||||
|
# job called `test` (`run: echo ok`) satisfied it — measured, and it escaped every
|
||||||
|
# workflow-population guard in the repo, 588 tests green.
|
||||||
|
if rel != GATING_WORKFLOW or GATING_JOB not in gates:
|
||||||
faults.append(
|
faults.append(
|
||||||
f"{rel}: job `{job_id}` publishes an image built from `{dockerfile}`, which carries "
|
f"{rel}: job `{job_id}` publishes an image built from `{dockerfile}`, which carries "
|
||||||
f"the SPA source, but does not transitively `needs:` `{GATING_JOB}` — the job "
|
f"the SPA source, but does not transitively `needs:` `{GATING_JOB}` IN "
|
||||||
f"holding the pinned suite step. It needs: {sorted(gates)}. Since ersatztv#887 the "
|
f"`{GATING_WORKFLOW}` — the job holding the pinned suite step. It needs: "
|
||||||
f"image build deliberately does not run the suite itself, so this `needs:` edge is "
|
f"{sorted(gates)}. A `needs:` edge resolves within its own workflow, so a job named "
|
||||||
f"the ONLY thing standing between a red suite and a published image."
|
f"`{GATING_JOB}` in a DIFFERENT workflow is a different job and gates nothing. Since "
|
||||||
|
f"ersatztv#887 the image build does not run the suite itself, so this edge is the "
|
||||||
|
f"ONLY thing between a red suite and a published image."
|
||||||
)
|
)
|
||||||
assert not faults, "\n".join(faults)
|
assert not faults, "\n".join(faults)
|
||||||
|
|
||||||
@@ -698,6 +732,23 @@ def test_the_npm_SCRIPT_MAP_is_exactly_what_was_reviewed() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_VITE_PLUGIN_LIST_is_exactly_what_was_reviewed() -> None:
|
||||||
|
"""A plugin can run the suite from a build hook, so the list of them is pinned.
|
||||||
|
|
||||||
|
Vite plugins execute in the same process as `vite build` and `vitest`, and a `buildStart()` that
|
||||||
|
shells out to `npm test` puts the suite back into the gitless stage with every command pin
|
||||||
|
intact — measured, both as an inline plugin and inside an existing one. The list is pinned so a
|
||||||
|
new plugin cannot arrive unnoticed; what each plugin DOES is a residual, and the mitigation
|
||||||
|
there is that `trackedSourceFilesPlugin` is deliberately lazy.
|
||||||
|
"""
|
||||||
|
text = _read(VITE_CONFIG)
|
||||||
|
assert text.count(PINNED_VITE_PLUGINS) == 1, (
|
||||||
|
f"`{VITE_CONFIG}`'s plugin list is not the pinned one ({PINNED_VITE_PLUGINS!r}). A vite "
|
||||||
|
f"plugin runs in the same process as the suite and can shell out from a build hook, so a "
|
||||||
|
f"new one is a decision to state. Update the pin deliberately."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_NO_OTHER_vitest_CONFIG_outranks_the_pinned_one() -> None:
|
def test_NO_OTHER_vitest_CONFIG_outranks_the_pinned_one() -> None:
|
||||||
"""A pinned config is worthless if a second file can take precedence over it.
|
"""A pinned config is worthless if a second file can take precedence over it.
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,9 @@ export function trackedSourceFilesPlugin(runGit?: GitRunner, fileExists?: FileEx
|
|||||||
return id === TRACKED_SOURCE_FILES_ID ? RESOLVED_ID : undefined;
|
return id === TRACKED_SOURCE_FILES_ID ? RESOLVED_ID : undefined;
|
||||||
},
|
},
|
||||||
// Deliberately LAZY: git runs only when something imports the virtual module. `vite build`
|
// Deliberately LAZY: git runs only when something imports the virtual module. `vite build`
|
||||||
// never resolves this id and so never shells out to git.
|
// never resolves this id and so never shells out to git. LOAD-BEARING FOR THE IMAGE BUILD since
|
||||||
|
// ersatztv#887: `docker/Dockerfile`'s web-build stage runs `npm run build` in a stage with no git
|
||||||
|
// binary, so making this eager would break every image build for #887's original root cause.
|
||||||
load(id) {
|
load(id) {
|
||||||
if (id !== RESOLVED_ID) {
|
if (id !== RESOLVED_ID) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user