Nothing couples ci-image.yml's push.paths to ci-image-pin's expected pathspec
#855
Closed
opened 2026-08-27 20:47:14 +02:00 by timothy
·
7 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
fix/830-add-items-error-surface
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#855
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Split out of #744, raised by its second cold review.
The gap
Two lists must name the same set of image sources, and only prose says so:
.gitea/workflows/ci-image.yml→on.push.paths— what causes a publish..gitea/workflows/pr-checks.yml→ci-image-pin'sexpected="$(git log -1 --format=%H -- docker/ci)"— what the pin is required to name.If they diverge, the failure is silent and green in the dangerous direction: a path that publishes but is not in
expectedproduces images the pin never tracks; a path inexpectedthat does not publish produces a permanent red on a blocking job.Why this PR made it load-bearing
Before #744 the two were kept identical by the self-reference —
.gitea/workflows/ci-image.ymlappeared in both, so an edit to either was felt immediately. #744 removed it from both as a decided tradeoff (ci.toolchain-image-publish-is-a-dispatch), which is the right call for its own reasons but leaves the agreement enforced by nothing but three prose comments.This is the exact shape
docs/defect-shapes-773.md§4 catalogues: a hand-maintained mirror of a population, with no derivation.Proposed
A
scripts/tests/case that parses both workflows and asserts set equality betweenci-image.yml'son.push.pathsand the pathspecci-image-pinpasses togit log. Both are in YAML, and theexpected=line is a singlerun:string, so the pathspec has to be extracted from it — that extraction is the part to get right, and pertesting.fixing-a-parser-bug-introduces-the-next-onebudget more than one round for it.It must ship with a declared clause mutation (
testing.mutation-claims-are-executed) and adocs/guard-inventory.mdrow, like every guard here. The obvious mutation: change one list and not the other.Note the population subtlety —
expected's pathspec isdocker/ci, whilepaths:isdocker/ci/**. Those denote the same set to their respective consumers but are not string-equal, so the comparison has to normalise rather than compare literally. A guard that demanded literal equality would be red on day one.Done-when
docs/guard-inventory.mdrowUnowned — explicitly declined, so it stops circulating. Recording this on the issue rather than only in session traffic, because four parallel sessions ran on 2026-08-30 and #855 was offered to each of us in turn: it was left for "whichever session is not doing #887", then declined by the #858/#859 session and the #887 session on disjointness grounds, then offered to me. The argument that assigned it to somebody was never completed by anybody, which is how an issue silently looks claimed while nobody is on it.
I am declining it too, with a reason rather than a hand-off: it is not the small guard it looks like. This issue's own body says the
expected=pathspec has to be extracted from a singlerun:string, thatpaths:isdocker/ci/**while the pathspec isdocker/ciso the comparison must NORMALISE rather than compare literally, and — citingtesting.fixing-a-parser-bug-introduces-the-next-one— to budget more than one round for that extraction. On top of that it needs a declared clause mutation and adocs/guard-inventory.mdrow. Starting it late in a session would leave a half-built guard, which is worse than leaving it clean.No work has been done on it, no branch exists, nothing is reserved. It remains
priority: mediumand is the top-ranked remaining candidate for a session with the runway to finish it in one go.Claiming — Claude Code session, 2026-08-30 13:40 CEST. Taking this with the full runway the previous comment said it needs; nothing about the scope changed, only the session's remaining budget.
Pre-claim checks all clear at
cf5f42edf: no open PR references #855,git ls-remote --heads origin '*855*'returns nothing, the only prior comment is an explicit decline, andorigin/mainwas re-fetched immediately before this comment.Plan follows the issue body rather than reinterpreting it: parse
on.push.pathsfrom.gitea/workflows/ci-image.ymland thegit logpathspec out ofci-image-pin'srun:string in.gitea/workflows/pr-checks.yml, normalise thedocker/civsdocker/ci/**difference rather than comparing literally, and assert set equality. Ships with a declared clause mutation inscripts/tests/mutation_manifest.py, adocs/guard-inventory.mdrow, and the three prose comments repointed at the guard.Will release this claim explicitly if I stop short.
Progress — claim still live, branch
fix/855-ci-image-paths-pin-agreement(not yet pushed). Recording this because the issue has been open under anin-progresslabel for several hours and a parallel session should not read that as a stalled claim.The guard is built and its two comparison clauses are each witnessed load-bearing. What has taken the time is the part this issue's body flagged: normalising
docker/ci/**againstdocker/cirather than comparing literally. Three cold reviews have returned BLOCKED, and every finding was one shape — two spellings a canonicaliser mapped together that the two consumers treat DIFFERENTLY:docker/ciinpaths:is an anchored match selecting none of the directory's contents, while the git pathspecdocker/ciis recursive;{a,b}is alternation to Gitea and a literal to git; a leading./is literal to Gitea and normalised away by git;<file>/**matches nothing while the pathspec<file>tracks the file.Three of round 2's five findings were created by round 1's fix, which is the trigger
testing.verification-code-needs-its-own-proofnames for STOP-AND-SUBTRACT. So the canonicaliser was deleted rather than extended. The guard now models exactly one pair of spellings —<dir>/**against<dir>, segments restricted to[A-Za-z0-9._-]— refuses everything else, and separately asserts from the git index that each named path really is a directory. Refusal is the safe direction here: a false red asks a question, a false green is the divergence this issue is about.Amendment to this issue's own guidance, from measurement. The body says to "normalise rather than compare literally". That is right about the goal and wrong about the method: a normaliser over these two dialects has an open-ended input space and cannot be made sound by adding cases. Anyone picking up similar work should model the one shape they need and refuse the rest.
Round 4 is running. If it finds another false green, I will withdraw the guard rather than patch a fifth time, and say so here.
Correction to my progress comment above — one of the examples I gave is wrong, and wrong in the direction that matters.
I wrote that "
{a,b}is alternation to Gitea and a literal to git". A later review measured that and it is inverted, or at least unestablished in both halves. What I have now measured myself: bash expands braces before git is ever executed (printf '%s' docker/{ci,extra}/xyields two paths), so on the pathspec side the shell is the thing that alternates; andshlex.split, which the guard uses to read the line as text, does not expand them. How Gitea's own matcher treats braces I have not measured, so the shipped guard now claims nothing about it in either direction.The refusal of a brace form is unchanged and still correct — it rests on the
shlexfact, which is measured — but the reason I gave for it was not established. Recording it here because that comment is the durable artifact a future session would read, and an unverified mechanism stated as fact is worse than no explanation.The examples that ARE measured against Gitea's compiler and real git, and that the design rests on: a bare
docker/ciinpaths:is an anchored match selecting none of the directory's contents while the pathspecdocker/ciselects all of them;<file>/**matches nothing while the pathspec<file>tracks the file; a leading/is literal to Gitea while git refuses it outright (fatal: Invalid path).Five review rounds have now each found a false prose claim — the shape #881 is open about. No round has found a false green.
Closing record — WITHDRAWN, not merged. Releasing the claim.
Outcome: the guard is built, green, and pushed as
fix/855-ci-image-paths-pin-agreement(headcc555659f, based on4cd692973). No PR was opened and nothing merged. Seven independent cold-review rounds found no false green and repeatedly confirmed the mechanism, but every round found at least one prose claim untrue of the shipped code, and rounds 4-7 each found one created by the previous round's fix. Rounds 6 and 7 both recommended withdrawing rather than correcting it an eighth time, and I had committed to withdrawing if round 7 was not clean. This issue stays OPEN.Root cause of the gap itself: #744 removed
.gitea/workflows/ci-image.ymlfrom bothon.push.pathsandci-image-pin'sexpectedas a decided tradeoff (ci.toolchain-image-publish-is-a-dispatch). That self-reference was what kept the two lists in step, and nothing replaced it.What is MEASURED and settled — do not re-derive this
Against Gitea 1.27.1's actual glob compiler (
gitea.com/gitea/actpkg/workflowpattern, ported independently by three reviewers and diffed againstnektos/act) and realgit ls-files:docker/ciselectsdocker/ci/**^docker/ci/.*\Z.excludes\n)docker/ci^docker/ci\Zdocker/ci/^docker/ci/\Zdocker/ci/**/*^docker/ci/(.+/)?[^/]*\Z[^/]does match\n)./docker/ci/**,/docker/ci/**,docker/{ci,extra}/**,docker/ci/**-- /docker/ci→fatal: Invalid path)The key negative result: canonicalising these two dialects into one string form cannot be made sound by adding cases. Two successive canonicalisers were built and both were defeated; three of one round's five findings were created by the previous round's fix. That is the
testing.verification-code-needs-its-own-proofstop-and-subtract trigger, and the design that survived deletes the canonicaliser: accept exactly<dir>/**against<dir>with segments[A-Za-z0-9._-], refuse everything else, and separately assert from the git index that each named path really is a directory (<file>/**matches nothing while the pathspec<file>tracks the file).Amendment to this issue's own guidance. The body says to "normalise rather than compare literally". Right about the goal, wrong about the method — a normaliser over these dialects has an open-ended input space. Model the one shape you need and refuse the rest; refusal is the safe direction.
Why it was withdrawn, precisely
Not the comparison — the ~78-line module docstring. Every round found a false claim in it, and the corrections kept generating new ones:
process.unknown-must-fail(a decision key I fabricated, cited twice, absent from the catalog); "{a,b}is alternation to Gitea and a literal to git" (inverted); "refused because this reader usesshlex— MEASURED, and it is the whole reason" (shlexis never called on that path;_SAFE_SEGMENTdoes the refusing); "a leading/is normalised by git" (git aborts); "each a false RED against some legitimate edit" (6 of 7 enumerated spellings are TRUE reds catching a broken trigger). Two reviewers independently concluded the narration is the mechanism producing the defects.What the next session should do — this is cheap from here
docs/guard-inventory.mdrows and the three repointed prose sites are all done and green (fullscripts/testspasses)._publish_directoryto admit a bare<dir>, which would report agreement for a trigger that publishes nothing); H-4 (lines 253-255 still call**/*a pair the consumers treat differently — false); H-5 (lines 39-42 claim the assignment binding covers the heredoc case; the<<clause refuses first); L-2 (the "complete refused set" omits >1git logassignment, no path after--, and the structural refusals); L-3 (a message says "ambiguity is refused" in the found-zero case, where there is none).<<clause. Adversarial-only, and NOT a regression — the pre-fix check missed it identically.Verification of what is on the branch: full
scripts/testsgreen (1433 passed, 2 skipped); every clause witnessed red by individual disarm underpython3 -B -p no:cacheprovider; the declared mutation re-run bytest_mutation_harness.pyreddens onlypublish-path-added; the co-ordinated-rename property executed (git mv docker/ci docker/toolchain+ both lists, no guard edit → 21 green).Reusable knowledge: (a) two glob dialects cannot be canonicalised into a common string form — model one shape, refuse the rest; (b) stop-and-subtract applies to PROSE as much as to code, and a subtraction that replaces per-item explanations with one blanket explanation is not a subtraction; (c) a mutation probe that restores with
git checkout -- <file>restores from the INDEX and silently reverts unstaged work — it cost me a full round's fixes here; back up to a scratch path instead; (d) two mutants of the same clause can produce same-size files within one mtime tick, and CPython's(mtime, size)invalidation then reuses the stale bytecode and attributes a red to the wrong mutant — usepython3 -B -p no:cacheprovider.Deferred: everything above. Docs updated: none merged.
Re-claiming — the withdrawal above is retracted, and the reason is worth stating rather than quietly reversing.
I withdrew because seven rounds each found a prose defect and I had committed to stopping. That was the right call about the narration and the wrong call about the guard: round 7's own diagnosis was that the ~78-line docstring is the mechanism producing the defects and that the honest subtraction is that narration, not the comparison — which is a deletion, not another correction, and deletions do not manufacture new claims.
So the docstring is cut from ~78 lines to 42 of operative fact. Deleted outright: the "each a false RED against some legitimate edit" list (measurably wrong for 6 of 7 spellings, and in the unsafe direction — it invited widening the check to admit a bare
<dir>, which is exactly the false green this guard exists to stop); the "complete refused set" claim, which was not complete; the assertion that the assignment binding covers the heredoc case, which the<<clause refuses first; and the inline comment calling**/*a pair the consumers treat differently, which measurement contradicts. Round 7's one functional finding is fixed too — a hard-coded["docker/ci"]that broke the co-ordinated-rename property the file advertises and failed with a message describing a refusal that never happened; the expectation is now derived, and the rename property is verified by executing it.Branch
fix/855-ci-image-paths-pin-agreement, now at3dbdff550. Fullscripts/testsgreen, every clause witnessed red by individual disarm. One more independent review round is running; if it is clean this merges, and if it is not I will say so here rather than patch a ninth time.Closing record
Outcome:
scripts/tests/test_ci_image_paths_pin_agreement.pyshipped in PR #902, squashed todd0f75f1b. It derivesci-image.yml'son.push.pathsandci-image-pin'sgit logpathspec from the two workflow documents and asserts set equality in both directions, with adocs/guard-inventory.mdrow, a declared clause mutation inscripts/tests/mutation_manifest.py, and the three prose sites repointed at it. The earlier withdrawal comment on this issue is superseded.Root cause: #744 removed
.gitea/workflows/ci-image.ymlfrom bothon.push.pathsandci-image-pin'sexpectedas a decided tradeoff (ci.toolchain-image-publish-is-a-dispatch). That self-reference was what kept the two lists in step, and nothing replaced it — the hand-maintained-mirror shapedocs/defect-shapes-773.md§4 catalogues.Decisions/conventions changed: none added. This applies
testing.guard-derives-population-from-source,testing.guard-ships-with-mutation-proof,testing.mutation-claims-are-executed, and the STOP-AND-SUBTRACT threshold intesting.verification-code-needs-its-own-proof, which is what decided the final design.Reusable knowledge — the negative result is the valuable part. Two glob dialects cannot be canonicalised into a common string form. Three designs were tried. Symmetric stripping mapped
docker/ciontodocker/ci/**— but a baredocker/ciin a Giteapaths:filter compiles to an anchored^docker/ciand selects NONE of the directory's contents, while the git pathspecdocker/ciselects all of it. Making the canonicaliser asymmetric fixed that pair and surfaced four more of the same shape ({a,b}, a leading./, a trailing/,<file>/**vs<file>), three of them created by the previous round's fix. What holds is a guard that models exactly ONE pair of spellings and refuses every other, because the refusal direction is safe: a red asks a question, a green over a divergence is the defect. Amendment to this issue's own guidance: the body said to "normalise rather than compare literally" — right about the goal, wrong about the method. Model the one shape you need and refuse the rest.Four more traps worth carrying:
modules/actions/workflowpattern->modules/glob.CompileWorkflow, NOTnektos/act'sPatternToRegex. They agree on the three patterns this guard rests on and DISAGREE on brace,**/xand character-class forms — exactly the spellings someone extending it would look up.paths: []is not "publishes nothing".Skipreturns false on an empty sequence, so an empty list filters nothing and every push publishes. Diagnosing it as an absence of publishing names the safe failure for the dangerous one.git checkout -- <file>restores from the INDEX and silently reverts unstaged work. It cost a full round's fixes here; back up to a scratch path instead, and commit before probing.(mtime, size)invalidation then reuses stale bytecode and attributes a red to the wrong mutant. Usepython3 -B -p no:cacheprovider.Verification: full
scripts/testsgreen (1435 passed, 2 skipped) and green in CI, includingPR Gates / Script lint and tests, which is the job that actually runs the guard. Every clause witnessed red by individual disarm; the declared mutation is re-run bytest_mutation_harness.pyevery suite and reddens onlypublish-path-added, its counterpart onlypathspec-extra-entry, soCLAUSEis the honest grade. The advertised co-ordinated-rename property was executed (git mv docker/ci docker/toolchainplus both lists, no guard edit -> green). Nine independent cold-review rounds, alternating Codex and a separate Claude line, each from a review-only brief in an isolated worktree; no round found a false green, and the last fuzzed 27,720 publish/pathspec pairs against a port of the deployed compiler and realgit ls-files.Deferred: (a) the guard compares the pathspec the pin job WRITES and does not establish that the staleness comparison consumes it; (b) the git-history half of
ci-image-pinstill has no test; (c) a descendant whose path below<dir>contains a newline is selected by the pathspec and not by the publish pattern; (d) restoringci-image.ymlto both lists is a file-against-file pair the guard refuses and would need a file arm —docs/ci-cd.mdnow forewarns about this next to the paragraph that calls the reversal workable; (e) one known nit shipped deliberately rather than polished a tenth time: theUnnormalisableclass docstring names_directorywhile the class has two raise sites. (a)-(d) are stated in the module docstring and the inventory rows rather than left implied.Docs updated:
docs/guard-inventory.md(new row, boundedci-image-pinworkflow-job row, summary counts 47/27),docs/ci-cd.md,.gitea/workflows/ci-image.ymland.gitea/workflows/pr-checks.ymlcomment sites.timothy referenced this issue2026-08-30 22:58:57 +02:00