pr-changed-files.sh head binding cannot see an A→B→A force-push across its paging round-trips
#664
Closed
opened 2026-07-26 23:28:42 +02:00 by timothy
·
2 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#664
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.
Found by the cold review of the #649 PR. Pre-existing — the script is unchanged by that PR (it landed in #658), which is why this was filed rather than fixed there.
The gap
scripts/pr-changed-files.shpagespulls/{n}/filesuntil it sees a validated empty page, then re-reads the PR head once and refuses if it differs from the expected sha. That closes A→B: pages assembled across a force-push belong to no single commit, and the final read catches it.It does not close A→B→A:
A. Page 1 returns 50 docs paths. A protected path sits on page 2.Bbefore page 2 is requested. Page 2 forBreturns[]— a validated empty page, socomplete=yes.Abefore the binding read.sha_after == expected_sha. The enumeration is declared complete and bound, over a partial list that omits the protected path.Both
complete=yesand the sha binding hold, and the result is a docs-only exemption for a PR that touches a protected path.Why this is hard rather than an oversight
The binding is a post-hoc check on a list assembled over several round-trips. Closing it properly needs one of:
pulls/{n}/fileshas noshaparameter, so there is nothing to pin to;The third is the only one that actually closes it, and it is a real redesign rather than a patch.
Exposure
Very low. It requires two precisely-timed force-pushes bracketing a specific HTTP request in a job that finishes in seconds, by the one human account with push rights, against a gate whose stated threat model is a careless change rather than a hostile one. Filing it so the limitation is written down where the enumeration's guarantees are described —
ci.shared-pr-file-enumerationcurrently reads as though head binding closes the race, and it closes only the one-way case.Done-when
ci.shared-pr-file-enumerationto "detects one-way head movement", or implement local diff computationClaiming as part of the #803 bundle — Claude Code session, 2026-08-28. #803 is the same defect written up independently (it also carries two siblings found while closing #778), so the head-ABA is being fixed once and both issues close together. Work and review rounds will be tracked on #803.
Closing record
Outcome: Closed by PR #873, together with #803 — the same defect, filed independently. #664 came out of the #649 cold review; #803 out of #778's read-then-write inventory. Fixed once: the enforced caller now fences its write on the monotonic
pull_pushcount, so anA -> B -> Aforce-push spanningpr-changed-files.sh's paging withholds the exemption instead of passing it.Root cause: The head binding is a post-hoc comparison of a value against itself, which is ABA-vulnerable by construction — this issue's own analysis was right that no tighter check inside the script can close it. What it under-weighted is that a caller CAN, with a monotonic key: the timeline's
pull_pushcount moves by two where the sha moves by zero.Decisions/conventions changed:
ci.shared-pr-file-enumeration— the script's guarantee is stated as one-way MOVEMENT DETECTION rather than a binding, in therule:, themechanics:and the body (the frontmatter still said "bound to BOTH the given sha", contradicting a body that had already been narrowed), and the new caller-side fence is recorded as covering the ENFORCED path only.ci.verdict-write-retarget-fenceamended to carry both axes.Reusable knowledge:
A -> B -> A. Writing it exposed the thing worth carrying: the ABA costs TWO push events, so a fence keyed on the count's VALUE rather than its MOVEMENT would withhold every exemption ever issued — every PR has a non-zeropull_pushcount, because it is created by a push.8798a1d -> 830a407 -> 8798a1dthrough ordinary force-push-and-revert.Verification: Covered by PR #873's gate — 1224 passed, 2 skipped; ruff clean; decisions-validate OK; CI green on the merged head. The
A -> B -> Areproduction istest_a_HEAD_ABA_DURING_the_run_posts_NOTHING, mutation-proved to redden when the fence arm is removed.Deferred: #870 — the timeline walk's
nullterminator is defeatable (Gitea pages before it filters), which caps what the fence closes. Documented in the record rather than left implicit, since this issue's own complaint was a guarantee stated more broadly than the code supports.Docs updated:
docs/decisions/records/ci/shared-pr-file-enumeration.md,docs/decisions/records/ci/verdict-write-retarget-fence.md,docs/decisions/records/release/verdict-status-check.md,docs/remote-state-inventory.md,docs/guard-inventory.md,docs/ci-cd.md,docs/decisions/README.md(regenerated).