post-review-verdict.sh does not check that its own account is on the gate's H10_REVIEWERS allow-list #845
Closed
opened 2026-08-26 21:46:10 +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#845
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.
Raised by the cold shell/polarity review of #742.
The coupling
#742 made
review-verdict.ymlinherit an existingreview-verdict/h10only when.creator.loginis in the workflow'sH10_REVIEWERSliteral (timothytoday).scripts/post-review-verdict.shis the tool that WRITES those verdicts. It posts with whatever account ownsETV_GITEA_TOKEN/ETV_GITEA_BASICAUTHand never checks whose account that is. The two values are now coupled and nothing asserts the coupling.The failure
The day the writer credential stops being an allow-listed account — a service token, a second machine, a renamed account, a bot minted for automation — every verdict silently stops being inheritable.
post-review-verdict.shreports success (the status really is written), and then the nextpull_request_targetevent re-derives it and postspendingover the human'ssuccess. And again on the next event. The PR deadlocks, and the only diagnostic is a::warning::inside a workflow run nobody is reading, because the tool that just ran said it worked.This is the stall direction, not the fail-open direction — no unreviewed code merges. But it is a repo-wide stall whose cause is two files apart.
Verified state at filing (2026-08-26)
Not a live bug: the token's
/api/v1/userlogin istimothy, the instance has exactly the accountstimothyandrenovate, andH10_REVIEWERS="timothy". Nothing keeps that true.Options
post-review-verdict.shresolvesGET /userafter auth and refuses (or warns loudly) when the login is not one the gate accepts. Needs the list in two places, which is the duplication #788 is separately trying to remove for the verdict vocabulary — so consider doing it the way #788 lands rather than adding a second hand-copied list..creator.loginagainst the same source. Stronger (it measures the effect rather than the intent) and it fits the re-read-and-compare the script already does.Option 2 plus 3 is the shape this repo usually lands on — assert the EFFECT, and let a guard hold the invariant.
Note on scope
Deliberately NOT bundled into #742:
post-review-verdict.shis on the merge-consent path and changing it is its own risk surface, and #742's## Done-whendid not require it.Done-when
ci.exemption-provenanceupdated to record the coupling as asserted rather than as a tracked residualClaiming this (Claude Code / Opus 5 orchestrator session, 2026-08-29). Checked before starting: no open PR references #845, no remote branch names it, no prior claiming comment,
origin/mainfreshly fetched at736649b3b.Planned shape is the issue's own recommendation — option 2 + 3:
post-review-verdict.shreads the status back and compares.creator.login, and the accepted set is DEDUPLICATED by construction (one declaration both the workflow and the script derive from) rather than hand-copied and detected. Not bundling #849/#858/#859 into this PR: they are the same cluster, but stacking three edits to the gate on one branch is the shape this repo has repeatedly had to withdraw.Closing record
Outcome: Shipped.
scripts/post-review-verdict.shnow verifies that the verdict it just wrote is one the gate will actually honour, instead of reporting success and leaving the PR to stall one event later. PR: http://192.168.1.95:3000/timothy/ersatztv/pulls/889 (squash-merged as5d955000f)Root cause: Two values were coupled with nothing asserting the coupling.
review-verdict.ymlinherits areview-verdict/h10=successonly from a creator on itsH10_REVIEWERSliteral (#742);post-review-verdict.shposted with whatever account ownedETV_GITEA_TOKEN/ETV_GITEA_BASICAUTHand never asked whose it was. The failure direction was a stall, never a fail-open — but a silent one, whose only diagnostic was a::warning::in a workflow run nobody reads.Decisions/conventions changed:
ci.exemption-provenanceupdated (Decisions-Edit: yes) — the coupling is recorded as ASSERTED at the writer rather than as a tracked residual, with the derivation, thesuccess-only scope, and the one false-accept (the local-checkout snapshot boundary) named with its direction. No new key.Reusable knowledge:
curlshim replayed the status POST payload as the read-back body. A POST legitimately usesstate; a status ROW serialises asstatus. The pair was self-consistent and both wrong, so reading.stateper row — which would have refused every verdict and deadlocked the repo — shipped green. Measured after the fixture was fixed: 25 tests redden; before, zero did. Probing the live server, not re-reading the code, is what found it.(.status // .state)as defensiveness meant the writer would accept a shape the gate cannot read and report success — #845 itself, reached through the check meant to prevent it. A verifier must read exactly what the thing it verifies reads.GET /commits/{sha}/statuspages, andtotal_countreports the PAGE, not the total — so a truncated body is indistinguishable from a complete one. But it selects each context's MAX row id and orders those DESCENDING, so a just-posted status is always on page 1. Getting that ORDER wrong in a fixture manufactured a truncation the server cannot produce, and made a?limit=parameter appear witnessed when its "witness" was circular.success/failureasymmetry is load-bearing on the writer side too. An existingfailureis inherited on attributability alone, so enforcing the allow-list on a rejection refuses a verdict the gate honours and leaves an off-list reviewer no supported way to record one.*, and a clause matching two sites and applied to neither — and each briefly read as "no test covers this".Verification: Full
scripts/testssuite green (1278 passed, 2 skipped) on the merged head; all PR checks green, includingReview verdict / Set review-verdict status, which INHERITED the posted verdict rather than re-deriving it — the membership coupling working end to end in production. The declared mutation executes every run and reddens its named proof with the declared diagnostic (scripts/post-review-verdict.shmoves BEHAVIOUR-ONLY → MUTATION, which the manifest's ownUNDECLAREDnote called "the most valuable upgrade on this list"). Every clause disarmed individually and confirmed to redden its own named test. Live probes against Gitea 1.27.1 for the row shape, the description round-trip, the paging order and the required-check list. jq expressions probed against hostile bodies (non-object array elements,creatoras a string,.statusesnull/non-array/empty) — all fail closed. NINE independent cold review rounds, all worktree-isolated, one cross-family (GPT-5.6 via Codex); round 9 returned MERGEABLE.Review rounds — what they cost and what they bought: the CODE was clean from round 4 onward; rounds 5-9 found only prose, and specifically prose trying to carry a number. Two findings were serious. Round 2 (cross-family) caught a
(.status // .state)fallback I had added as defensiveness that RECREATED #845 — the writer would accept a shape the gate cannot read and report success. Round 8 caught aset -u"correction" that had INVERTED a true statement in live merge-gate code, because my probe used a plain$UNSETwhile the validator uses${#arr[@]}; it was withdrawn wholesale and both libraries are byte-identical tomainagain. ersatztv#886, filed during that detour asking for a decision record to be "corrected", is CLOSED as invalid — the record was right.Also fixed here, found by the same rounds: the CI guard
test_the_H10_REVIEWERS_list_is_glob_free_and_non_emptyparsed the classify step'srun:body while the library greps the whole YAML, so a second anchored assignment elsewhere was invisible to the guard while making the writer refuse every verdict; andci.script-tests-jobstill enumerated a two-directory input set after this PR raised both derived copies to three.Deferred: The refused-verdict residual — a non-inheritable status left standing with no comment beside it — is deliberately not repaired here. That is the
askhalf-staterelease.verdict-writes-status-before-commentdesignates as safe, and a second corrective write is the sticky-sentinel mechanism #849 is separately designing. The snapshot boundary (the writer reads the LOCAL workflow while the gate runs the base-resolved one) is accepted with its direction recorded: on a branch that ADDS a reviewer it is a false accept, one PR long, and a stall rather than an unreviewed merge.Docs updated:
docs/decisions/records/ci/exemption-provenance.md(+ regenerateddocs/decisions/README.md),docs/ci-cd.md,docs/guard-inventory.md,docs/remote-state-inventory.md,CLAUDE.md, and thescript-testspopulation comment in.gitea/workflows/pr-checks.yml.