review-verdict.yml's classify/post step never executes — job goes green, review-verdict/h10 is never posted automatically #751
Closed
opened 2026-08-06 14:36:21 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
renovate/meziantou.analyzer-3.x
renovate/nuget-sixlabors.imagesharp-vulnerability
913-scripted-playout-401
929-vuln-pkcs-imagesharp-v4
renovate/dotnet-monorepo
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
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.16.0
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#751
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 while running the #748 scratch-base probe. Not caused by #748, and not caused by the 1.27.1 upgrade — it predates both.
The defect
review-verdict.yml's step "Classify the PR and post the review-verdict status" never executes, and the job reportssuccessanyway.In the job log the runner emits:
…and then jumps straight to the post-checkout cleanup. The step markers confirm it:
There is no
⭐ Run Main Classify the PR and post the review-verdict status, no stdout from the script, and no success/failure marker for it. The step is silently dropped and the job goes green.How long
Present in run 1832 (2026-08-04T21:46Z), which is before the Gitea 1.25.4 → 1.27.1 upgrade, and in every review-verdict run I sampled since (1844, 1849, 1852, 1856). So this is not an upgrade regression. Not bisected further back.
Consequences
review-verdict/h10is never posted automatically. The only thing that has been setting it is a human/agent runningscripts/post-review-verdict.sh. On PR #749 the context was simply absent after a fully green CI run until I posted it by hand.h10at all, so they cannot satisfy the required check. Worth checking whether recent dependency PRs have been silently unmergeable.Review verdict / Set review-verdict status) reports success, so nothing surfaces.docs/ci-cd.mddeliberately keeps that context out of branch protection precisely so a workflow cannot satisfy the gate by merely running — that design decision is what stopped this from being much worse.Why it blocks #748
#748 wants to declare
permissions:on this workflow so the owner default can move to Restricted. The whole point of the scratch-base probe is to prove the declared scope still permitsPOST /statuses/{sha}. That is unprovable while the step that would POST never runs. #748 should stay blocked until this is fixed.Likely cause (unconfirmed)
The
run:body is a very large shell script containing${{-adjacent text, backticks and\nsequences; the runner is rewriting it throughformat(...)and failing to interpolate. Suspect something in the script body is being parsed as an expression. A minimal repro would be to bisect the script comment block out of therun:step.Done-when
run:body defeats interpolation)review-verdict/h10on a scratch-base probe PR (probe-named context, never the real one)Claiming this (Claude Code session, 2026-08-06).
Root cause found — it is line 413 of the workflow itself, inside a shell comment.
The
run:body contains exactly one${{ }}sequence, and it is not meant as an expression:The runner does not know that is a shell comment. It scans the whole
run:scalar for${{, finds that one, and therefore rewrites the entire 675-line body into a singleformat(...)call so it can splice the expression result back in — which is exactly theUnable to interpolate expression 'format('set -euo pipefail\n\nCONTEXT="review-verdict/h10"...in the log.pr numberis not a valid expression, the interpolation of the whole scalar fails, and the step is dropped. One stray token in prose takes the entire step with it, because the format() rewrite is all-or-nothing.It dates the outage precisely.
git log -S/git blameput that line in8f6d4f443(fix(706,707,711): fence the review-verdict write on the timeline retarget count, 2026-08-03) — the commit that added thecount_retargetsnote explaining why a concurrency group does not work here. So the window opens 2026-08-03, which is consistent with the report (present in run 1832 on 2026-08-04, predates the 1.27.1 upgrade) and closes the "not bisected further back" gap.Grimly apt: the workflow was disabled by the prose documenting a fix, not by the fix.
Plan — the third Done-when box (silent green) is the real defect, so the fix is at the boundary, not the site:
${{can appear in arun:body.${{occurs inside anyrun:scalar in.gitea/workflows/*.yml. This is the boundary fix — the specific line is just the first instance.Not bundling #742 (the other open issue touching this file). #751 is an urgent surgical restore of a dead required gate; #742 changes the provenance model of the same 857-line security-critical file. Landing them together would enlarge the review surface on a gate that is currently non-functional. #742 goes after this merges.
Probe evidence (live, on this instance)
Both halves verified with a scratch-base probe pair per the documented procedure in
docs/ci-cd.md. Probe contextprobe751/h10throughout — never the real one, since a status forged on a scratch head is inherited by any later PR carrying the same head sha (#663).Negative control — PR #753, run 1863: a dropped step is now RED
This also answers the question the original report could not: a later step DOES still run after the runner drops an earlier one. The classify step was previously the job's last step, so there was never a subsequent step to observe. The guard therefore works, and the ~700-line body does not need to move into
scripts/.Positive — PR #752, run 1866: the step executes AND posts
The probe found a SECOND defect, and it is why box 2 needed two rounds
Round 1 of the positive probe ran the classify step successfully and still posted nothing:
A page past the end of
GET /issues/{n}/timelineis the JSON valuenull, not[]— four bytes, measured at Gitea 1.27.1.count_retargetsgated ontype == "array", so it read the real terminator as unreadable, never reached a validated empty page, andrt_okwas neveryesfor any pull request. The #706 fence therefore withheld every exemptionsuccess. Fixing the interpolation alone would not have restored the exemptions — the same user-visible outcome as this issue, by an unrelated route.The instance is not even consistent between endpoints:
/issues/{n}/commentsreturns[]when empty. Both shapes now terminate the walk, and the regression test is parameterised over both.Two reasons it read as deliberate rather than broken:
8f6d4f443— the same commit whose prose comment stopped the step executing. Merging a guard and first executing it are different events.[]past the end. Correcting the double and restoring the old gate turns 18 tests red — all of them had been green for the wrong reason.Box 4 — blast radius audit
Window 2026-08-03 21:41 (bad commit) → now, all PRs:
Review-verdict:withcreator=timothy— normal path, no separate holeNothing is stuck right now (no open PRs besides the probes). But #743 disabled admin force-merge on 08-06 00:58, so the escape that let #739 through is gone: the next docs-only or Renovate-manifest PR would have been permanently unmergeable with no bypass.
Also fixed, both found while editing this code
ci.verdict-write-retarget-fencestated this as a narrow residual ("a timeline over the 20-page cap can never be exempted —rt_okstaysnoon every run") when the behaviour was universal. Corrected in place, since a stale claim that reads as checked talks the next reader out of verifying.ci.paged-endpoint-completeness, a key that has never existed as a record anywhere. Repointed at the record that actually owns this walk.RUNNER_TEMPis/tmpon this runner, not a private per-job dir, so a stale marker could have made the guard pass on a run whose step was dropped.Independent adversarial review (cross-family + a second cold lens) is running now. PR to follow once it is clean — not before.
Closing record
Outcome: Fixed and merged in #764 (
9a5d34e88). Three independent defects, each alone enough to stopreview-verdict/h10being posted:${{ }}sequence in a shell comment in the classify step. The runner scans the wholerun:scalar for the expression opener and rewrites the entire body into oneformat(...)call;pr numberdoes not parse, so it drops the step and concludes the job green. Introduced by8f6d4f443(2026-08-03) — the #706 note explaining why a concurrency group does not work, quoting aconcurrency:snippet as illustration. The prose documenting a fix disabled the fix./issues/{n}/timelineis JSONnull, not[];count_retargetsgated ontype == "array", sort_okwas neveryesfor any PR and every exemptionsuccesswas withheld. Found by running the probe — fixing (1) alone would not have restored the exemptions./commits/{sha}/status, which maderead_existing_verdicttake itsexit 1path and post nothing. Found by cold review of the fix for (2).Root cause: For (1), a
run:body is not shell when the runner reads it — a comment is not inert, and theformat(...)rewrite is all-or-nothing. For (2) and (3), a nil Go slice serialises tonull, so every list-shaped field on this API is suspect. Four endpoints, three shapes:timeline→ barenull;commits/{sha}/status→{"statuses": null};commentsandpulls/{n}/files→[];statuses/{sha}→[]. Only a per-endpoint measurement settles it.Decisions/conventions changed: added
ci.workflow-run-body-no-expressions; amendedci.verdict-write-retarget-fence(its stated residual described the universal failure as a narrow over-cap edge case) andci.exemption-provenance(PROTECTED now coversCLAUDE.md/AGENTS.md; thestatuses: nulltolerance). The workflow citedci.paged-endpoint-completeness, a key that has never existed as a record — repointed.Reusable knowledge:
⭐ Run Main <step>, and check DURATION: the broken job succeeded in 6s, the fixed one takes 14–17s because it actually classifies and posts.[]past the end while its comment read "Real shapes, measured on this instance and deliberately mirrored". Correcting it and restoring the old gate reddens most of the fence suite — every one of those tests had been green for the wrong reason.limit=100that was dead code, because Gitea clamps toMAX_RESPONSE_ITEMS= 50 — documented inscripts/pr-changed-files.sh, two test files andci.script-tests-jobbefore the guard was written. Ask the server (read page 2) instead of hardcoding a cap.total_counton the combined status endpoint is per-PAGE, not per-commit, so length-vs-total cannot detect truncation. Three guards were written for that one hole; the first two were no-ops.DOCS_ONLYmatchedCLAUDE.md/AGENTS.md, so the documents defining the H10 rule were docs-only-auto-exemptible. Dead while the step was dropped; restoring the exemptions re-opened it.Verification: 460 tests; 29 mutations across six rounds, including two controls that must not red (an inert top-level YAML comment, a legitimate step deletion). Live scratch-base probe pair with a negative control, six rounds, the last on the merged executable body (byte-identical — only comments changed after it): positive posts the docs-only exemption with
trusted=yes; negative drops the step and the new guard fails the job with the context absent, which is what established that a later step still runs after a drop. Post-merge, end-to-end: #761 and #762 — Renovate PRs that carried noh10and could not merge — were re-triggered by a body edit and both now readh10 = success | Exempt: authored by the renovate bot account… | creator=null. Five cold review rounds, alternating model families; none found a path to a greenh10on an unreviewed head, and every round found a defect beside the fix.Deferred: #756 —
docker-build.ymlstest/migrationsare also required contexts and there a dropped step is fail-OPEN (green having done no work), strictly worse than here; this PR guarded the file where the failure mode is safe, because that is where the live bug was. #763 —/statuses/{sha}is read twice unpaged whilelimitclamps to 50, mitigated here conservatively (unread or unreadable page 2 → assume raced → repair topending, which is sticky per-sha) pending real paging.Docs updated:
docs/ci-cd.md(Review-verdict gate),docs/decisions/records/ci/workflow-run-body-no-expressions.md(new),docs/decisions/records/ci/verdict-write-retarget-fence.md,docs/decisions/records/ci/exemption-provenance.md,docs/decisions/README.md(regenerated).