fix(855): two glob dialects cannot be canonicalised into one, so model one shape and refuse the rest #902

Merged
timothy merged 1 commits from fix/855-ci-image-paths-pin-agreement into main 2026-08-30 21:25:24 +02:00
Owner

fixes #855

The gap

Two lists had to name the same CI-toolchain image sources, and only prose said so:

  • .gitea/workflows/ci-image.ymlon.push.paths — which pushes to main publish a toolchain image.
  • .gitea/workflows/pr-checks.ymlci-image-pin's expected="$(git log -1 --format=%H -- docker/ci)" — what the pin must name.

#744 removed the shared self-reference that had kept them in step (ci.toolchain-image-publish-is-a-dispatch), leaving the agreement carried by three prose comments. Divergence is silent and green in the dangerous direction: a path that publishes but is not in the pathspec produces images the pin never tracks; a path in the pathspec that does not publish reddens a blocking job forever.

What ships

scripts/tests/test_ci_image_paths_pin_agreement.py derives BOTH lists from the workflow documents and asserts set equality in both directions, with its docs/guard-inventory.md row, a declared clause mutation in scripts/tests/mutation_manifest.py, and the three prose sites repointed at the guard.

The comparison is deliberately narrow, and that is the substance. It accepts a publish entry spelled exactly <dir>/** against a pathspec entry spelled exactly <dir>, segments restricted to [A-Za-z0-9._-], and raises on every other spelling rather than deciding what that spelling would have selected. Measured against Gitea 1.27.1's compiler (gitea.com/gitea/act pkg/workflowpattern) and real git:

spelling Gitea compiles to selects
docker/ci/** ^docker/ci/.*\Z the subtree, except a newline-named descendant
docker/ci ^docker/ci\Z nothing under the directory — while the pathspec docker/ci selects all of it
<file>/** anchored literal nothing — while the pathspec <file> tracks the file
/docker/ci/** anchored literal nothing — and git log -- /docker/ci aborts, fatal: Invalid path

A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling — the trigger testing.verification-code-needs-its-own-proof names for stop-and-subtract — so it was deleted rather than extended.

The guard also asserts from the git index that each named path really is a directory (<file>/** and the pathspec <file> spell the same string); takes the pathspec from the git log assignment rather than any git log in the job, so an inert one in a comment or heredoc is not adopted; and refuses a << token on a code line (a herestring excluded, since pr-checks.yml already uses one) and a second bare --, because telling a path separator from an option argument needs git's option arity and refusing needs nothing.

The docstring states two boundaries rather than implying coverage: the guard compares the pathspec the pin job writes and does not establish that the staleness comparison consumes it; and a descendant whose name contains a newline is matched by the git pathspec but not by the publish pattern.

Verification

  • 21 tests in the new file; full scripts/tests green (1433 passed, 2 skipped).
  • Every clause witnessed red by disarming it individually under python3 -B -p no:cacheprovider — two mutants of the same clause produce same-size files within one mtime tick, and stale bytecode once attributed a red to the wrong mutant.
  • The declared mutation is executed by test_mutation_harness.py every run: disarming published - required reddens only publish-path-added; disarming required - published reddens only pathspec-extra-entry. Each clause is separately load-bearing, so CLAUSE is honest.
  • The advertised property executed: git mv docker/ci docker/toolchain plus both workflow lists, with no edit to the guard, leaves the file green; a one-sided edit reddens only the agreement test.
  • Eight independent cold-review rounds (Codex and a different Claude line, each from a cold review-only brief in an isolated worktree). No round found a false green. Every finding was reproduced locally before fixing and pinned as a regression test where it had one.

Done-when

  • A guard asserts the two lists agree, deriving both from the workflow files rather than restating either
  • It ships a declared clause mutation re-run every suite, and a docs/guard-inventory.md row
  • The three prose comments that currently carry this invariant point at the guard
  • Adversarial review passed
fixes #855 ## The gap Two lists had to name the same CI-toolchain image sources, and only prose said so: - `.gitea/workflows/ci-image.yml` → `on.push.paths` — which pushes to `main` publish a toolchain image. - `.gitea/workflows/pr-checks.yml` → `ci-image-pin`'s `expected="$(git log -1 --format=%H -- docker/ci)"` — what the pin must name. #744 removed the shared self-reference that had kept them in step (`ci.toolchain-image-publish-is-a-dispatch`), leaving the agreement carried by three prose comments. Divergence is silent and green in the dangerous direction: a path that publishes but is not in the pathspec produces images the pin never tracks; a path in the pathspec that does not publish reddens a blocking job forever. ## What ships `scripts/tests/test_ci_image_paths_pin_agreement.py` derives BOTH lists from the workflow documents and asserts set equality in both directions, with its `docs/guard-inventory.md` row, a declared clause mutation in `scripts/tests/mutation_manifest.py`, and the three prose sites repointed at the guard. **The comparison is deliberately narrow, and that is the substance.** It accepts a publish entry spelled exactly `<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to `[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would have selected. Measured against Gitea 1.27.1's compiler (`gitea.com/gitea/act` `pkg/workflowpattern`) and real git: | spelling | Gitea compiles to | selects | |---|---|---| | `docker/ci/**` | `^docker/ci/.*\Z` | the subtree, except a newline-named descendant | | `docker/ci` | `^docker/ci\Z` | **nothing under the directory** — while the pathspec `docker/ci` selects all of it | | `<file>/**` | anchored literal | nothing — while the pathspec `<file>` tracks the file | | `/docker/ci/**` | anchored literal | nothing — and `git log -- /docker/ci` aborts, `fatal: Invalid path` | A canonicaliser mapping the two dialects onto one string form was built twice and defeated twice, each repair surfacing another spelling — the trigger `testing.verification-code-needs-its-own-proof` names for stop-and-subtract — so it was deleted rather than extended. The guard also asserts from the git index that each named path really is a directory (`<file>/**` and the pathspec `<file>` spell the same string); takes the pathspec from the `git log` **assignment** rather than any `git log` in the job, so an inert one in a comment or heredoc is not adopted; and refuses a `<<` token on a code line (a herestring excluded, since `pr-checks.yml` already uses one) and a second bare `--`, because telling a path separator from an option argument needs git's option arity and refusing needs nothing. The docstring states two boundaries rather than implying coverage: the guard compares the pathspec the pin job *writes* and does not establish that the staleness comparison consumes it; and a descendant whose name contains a newline is matched by the git pathspec but not by the publish pattern. ## Verification - 21 tests in the new file; full `scripts/tests` green (1433 passed, 2 skipped). - Every clause witnessed red by disarming it individually under `python3 -B -p no:cacheprovider` — two mutants of the same clause produce same-size files within one mtime tick, and stale bytecode once attributed a red to the wrong mutant. - The declared mutation is executed by `test_mutation_harness.py` every run: disarming `published - required` reddens only `publish-path-added`; disarming `required - published` reddens only `pathspec-extra-entry`. Each clause is separately load-bearing, so `CLAUSE` is honest. - The advertised property executed: `git mv docker/ci docker/toolchain` plus both workflow lists, with no edit to the guard, leaves the file green; a one-sided edit reddens only the agreement test. - Eight independent cold-review rounds (Codex and a different Claude line, each from a cold review-only brief in an isolated worktree). No round found a false green. Every finding was reproduced locally before fixing and pinned as a regression test where it had one. ## Done-when - [x] A guard asserts the two lists agree, deriving both from the workflow files rather than restating either - [x] It ships a declared clause mutation re-run every suite, and a `docs/guard-inventory.md` row - [x] The three prose comments that currently carry this invariant point at the guard - [x] Adversarial review passed
timothy added 1 commit 2026-08-30 21:01:49 +02:00
fix(855): two glob dialects cannot be canonicalised into one, so model one shape and refuse the rest
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 16s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
PR Gates / decisions lifecycle (pull_request) Successful in 25s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 14s
Review verdict / Set review-verdict status (pull_request_target) Successful in 25s
review-verdict/h10 Review-verdict: MERGEABLE @ 5e15dd1 (base: main)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 16m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m44s
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 6s
5e15dd1344
`ci-image.yml`'s `on.push.paths` decides which pushes to `main` publish a toolchain image;
`ci-image-pin`'s `git log` pathspec decides what the pin must name. #744 removed the shared
self-reference that kept them in step, leaving the agreement carried by three prose comments, and
divergence is silent and green in the dangerous direction.

The guard derives both lists from the workflow documents and compares them for set equality in both
directions. The comparison is deliberately narrow: it accepts a publish entry spelled exactly
`<dir>/**` against a pathspec entry spelled exactly `<dir>`, segments restricted to
`[A-Za-z0-9._-]`, and raises on every other spelling rather than deciding what that spelling would
have selected.

That narrowness is the substance. Measured against Gitea 1.27.1's compiler and real git, a bare
`docker/ci` in `paths:` compiles to an anchored `^docker/ci` and selects none of the directory's
contents while the git pathspec `docker/ci` selects all of them; `<file>/**` matches nothing while
the pathspec `<file>` tracks the file; a leading `/` is literal to Gitea while git refuses it
outright. A canonicaliser mapping the two dialects onto one string form was built twice and defeated
twice, each repair surfacing another spelling, so it was deleted rather than extended per
`testing.verification-code-needs-its-own-proof`.

The guard also asserts from the git index that each named path really is a directory, since
`<file>/**` and the pathspec `<file>` spell the same string; takes the pathspec from the `git log`
assignment rather than any `git log` in the job; and refuses a `<<` token on a code line (a
herestring excluded) and a second bare `--`, because telling a path separator from an option
argument needs git's option arity and refusing needs nothing.

The docstring states the boundaries rather than implying coverage: the guard compares the pathspec
the pin job writes and does not establish that the staleness comparison consumes it, and a descendant
whose name contains a newline is matched by the git pathspec but not by the publish pattern.

fixes #855
Author
Owner

Review-verdict: MERGEABLE @ 5e15dd1

Nine cold review rounds, no false green found in any. Round 9 fuzzed 27720 pairs against a port of Gitea 1.27.1 own compiler and returned all severity bands empty on this head. Full scripts/tests green 1435 passed 2 skipped.

Review-verdict: MERGEABLE @ 5e15dd1 Nine cold review rounds, no false green found in any. Round 9 fuzzed 27720 pairs against a port of Gitea 1.27.1 own compiler and returned all severity bands empty on this head. Full scripts/tests green 1435 passed 2 skipped.
timothy merged commit dd0f75f1b6 into main 2026-08-30 21:25:24 +02:00
Sign in to join this conversation.