fix(ci): read the reviewed label live inside the job, not from the frozen event payload (BACKLOG #1417) - #784
fix(ci): read the reviewed label live inside the job, not from the frozen event payload (BACKLOG #1417)#784wshallwshall wants to merge 2 commits into
Conversation
…ozen event payload (BACKLOG #1417) `a reviewer has read this` is a required status check on `main` with `enforce_admins: true`, and it could report SUCCESS on a pull request carrying no `reviewed` label. The step read `join(github.event.pull_request.labels.*.name, ',')`, which is the label set as it stood when the webhook FIRED, while branch protection picks the newest check-run by EXECUTION time. Measured on PR 724 on 2026-09-01: a run created 13:24 executed at 13:43:46, twenty-odd seconds after the label was removed, and reported success from its 13:24 payload. That success stood for ten minutes with no label on the pull request; only `strict = true`, an unrelated control, kept it from merging. THE STEP NOW READS THE LABEL LIVE, with `gh pr view --json labels` from inside the running job. Reading the status context instead is not a fix -- it inherits the same staleness through the same snapshot -- and had already been adopted and refuted twice. AND THE LABEL MUST POST-DATE THE HEAD IT SITS ON. A live read answers "is the label there now", never "did anyone read THESE commits". So the step also compares the newest `reviewed` labeled event against the head commit's date and refuses a label older than the commit it sits on. Where the run was started by a reviewer applying the label, that comparison is answered by the event itself and no history read happens: a GitHub clock, and the one timestamp that cannot lag a write made seconds earlier. The history read uses `per_page=100` with `--paginate`, because every `gh api` list route defaults to 30. THE `synchronize` ARM STAYS, and it is no longer the fix. The removal step runs immediately before it and the labels endpoint is not guaranteed to have caught up; and the head-date comparison rests on a commit's committer date, which a client supplies, so a commit authored before the label and pushed after it would clear that comparison while being genuinely unread. What this removes is the other half: every action EXCEPT `synchronize` reading the snapshot. IT PASSES MORE OFTEN IN EXACTLY ONE DIRECTION, and that is stated rather than left to be found. Where the payload was stale-positive the gate now refuses. Where it was stale-negative -- a run queued before the label was applied, executing after -- it now passes, because at execution time the label is present and post-dates the head. That is the correct verdict on a state the old step could only get wrong. The head-date comparison only ever ADDS a refusal, so a spoofed commit date cannot open the gate, and every failure of the two API reads exits non-zero under `-e`, which blocks. PERMISSIONS. Declaring any permission sets the rest to `none`, so the two reads are named: `contents: read` for the head commit's date and `issues: read` for the label events. A 403 would fail closed, which is the right direction but would wedge every pull request. MEASURED BEFORE AND AFTER, one input, both shells run under the flags Actions uses. The pre-fix shell was lifted from `git show HEAD:.github/workflows/review-gate.yml` rather than retyped, and fed PR 724's state -- payload carries `reviewed`, live read returns nothing: before exit 0 "reviewed label present. Gate satisfied." after exit 1 "labels, read at execution time: (none)" plus the remedy CONTROLS. Four new planted violations and three new asymmetry arms in tests/test_merge_gate_controls.py, registered in tests/negative_controls.toml. The gate's own shell is still lifted from the workflow rather than re-implemented; its `gh` calls are answered by a shim whose unanswered call is a loud harness fault, not a silent refusal -- empty output reads as "no label" everywhere in this step, so a broken shim would have looked exactly like the gate working. The static arm's detector is asserted against its own false positive: the workflow's comment NAMES the banned expression, and a whole-file scan would have "fixed" that by deleting the sentence recording this item. NO DEPLOYMENT AXIS. This reaches the repository's own merge control, not shipped code. The cost is an unreviewed change landing on `main`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cross-PR reconciliation: 783 and 784 fix the same defect in the same file, and they conflictPosted by a Builder dispatched to reconcile BACKLOG #1417 against PR 783. On arrival I found PR 784 The conflict is measured, not predicted
Both rewrite the same Rule by rule: what PR 783 closes of #1417#1417 numbers five rules. Rules (1) to (4) are a detection procedure for a reader; rule (5) is the
Rule (2) inverts under 783. Under the payload gate, the originating run's Rule (4): the residual is real and 783 states it in its own header. A Rule (5) is reachable under 783 alone, not merely unaddressed. 783 adds no head sha, no commit What each pull request has that the other lacksNeither is a subset of the other, which is why the land order matters beyond the rebase cost.
The residual nobody has recorded: #1417's row goes stale if 783 lands alone#1417's row on So if 783 lands first, someone has to update #1417's row separately, or #1417 keeps asserting an Neither pull request closes #1417, and that is right. 784 keeps the open banner and says "BUILT Two observations for the reviewers, not fixesI did not edit
Open question for the ConsoleThe claim on #1417 is held by a worktree with no live session in it, and |
|
LANDER: HOLDING THIS ONE, LANDING 783 FIRST. Three findings, all measured here rather than taken on report. 1. The two PRs genuinely conflict. 2. The shell bug a reviewer flagged is real. No 3. The one I did not see reported, and it is why I am holding rather than reordering. The check compares the label event against WHY 783 GOES FIRST. It changes WHERE the label comes from: the step asks the API what the labels ARE instead of reading a frozen webhook payload. That is foundational -- any head-date check sits on top of whatever read mechanism exists. It carries no reported defect, seven test arms, and it states its own residual honestly ("narrows the window, does not close it to zero"), which is the SDS-3.7 shape rather than a compensating control resting on a false premise. WHAT THIS PR STILL UNIQUELY CARRIES, and should not be lost. It is the only one fixing RECOMMENDED SHAPE: rebase this onto 783 and reduce it to the doc corrections plus the #1417 ledger row, dropping the workflow change 783 supersedes. If the head-date check is still wanted, it needs the run clock rather than the commit clock, plus Not closing it -- that is a content call for whoever owns it. Ping me when it is reshaped and I will land it. -- Lander |
What this fixes
a reviewer has read thisis a required status check onmainwithenforce_admins: true, and it could report SUCCESS on a pull request carrying noreviewedlabel. The step readjoin(github.event.pull_request.labels.*.name, ',')— the label set as it stood when the webhook fired — while branch protection picks the newest check-run by execution time. Measured on PR 724 on 2026-09-01: a run created 13:24 executed at 13:43:46, twenty-odd seconds after the label was removed, and reported success from its 13:24 payload. That success stood for ten minutes with no label on the pull request.No deployment axis (CLAUDE.md section 0). This reaches the repository's own merge control, not shipped code. The cost is an unreviewed change landing on
main— not anything an adopter would run.The item was not already answered
Checked before building, because three items dispatched this morning carried stale targets.
git log --oneline -20 -- .github/workflows/review-gate.ymlreturns two commits,ab6e40f7e(the gate) and43849fe87(BACKLOG #1404, arming it). Neither touches the label read.docs/adr/names this gate or #1417..github/workflows/review-gate.yml:105still readLABELSfrom the payload and line 112 still hard-coded thesynchronizespecial case at my base commitfd44b0f17. The defect was intact.The change
The step reads the label live.
gh pr view "$NUMBER" --json labelsfrom inside the running job. Reading the status context instead is not a fix and was already adopted and refuted twice — it inherits the same staleness through the same snapshot.And the label must post-date the head it sits on. A live read answers "is the label there now" and never "did anyone read THESE commits". So the step also compares the newest
reviewedlabeled event against the head commit's date and refuses a label older than the commit it sits on. That is the item's rule (5), the one the first draft shipped without. Where the run was started by a reviewer applying the label, the comparison is answered by the event itself and no history read happens: a GitHub clock, and the one timestamp that cannot lag a write made seconds earlier. The history read usesper_page=100with--paginate, because everygh apilist route defaults to 30.Permissions. Declaring any permission sets the rest to
none, so the two reads are named explicitly:contents: readfor the head commit's date,issues: readfor the label events. A 403 would fail closed, which is the right direction but would wedge every pull request.Where I deliberately did not follow the brief, and why
The brief says "stop special-casing one action". I generalised the remedy to every action but kept the
synchronizerefusal, and it is worth reading the reason before treating this as ducking the item:So the special case is no longer the fix; it is a belt over the live read. What the change removes is the other half — every action except
synchronizereading the snapshot. If a reviewer disagrees, deleting those five lines is a one-line revert and the two behavioural controls around it still hold.Could this make the gate pass more often? Yes, in exactly one direction
Called out because the brief asks for it explicitly.
Reading live cuts both ways. Where the payload was stale-positive the gate now refuses — that is the defect. Where it was stale-negative (a run queued before the label was applied, executing after) the gate now passes, because at execution time the label is present and post-dates the head. That is the correct verdict on a state the old step could only get wrong, and a stale FAILURE was safe but still wrong: it wedged a pull request nothing else would re-run. It is pinned by
test_the_review_gate_clears_a_pull_request_labelled_after_the_run_was_queued.Two things that cannot fail open:
-e, which blocks.Which arm the test pins, and the before/after
The behavioural control is
test_the_review_gate_ignores_the_label_set_frozen_in_the_event_payload. It plants exactly PR 724's state:LABELS=reviewed(the variable the pre-fix step read out of the payload) while the live read returns nothing.Measured by running the pre-fix shell, lifted from
git show HEAD:.github/workflows/review-gate.ymlrather than retyped, through the post-fix control, under the flags Actions uses:reviewed label present. Gate satisfied.labels, read at execution time: (none)plus the remedyFour new planted violations and three new asymmetry arms, registered in
tests/negative_controls.toml. The gate's own shell is still lifted from the workflow rather than re-implemented. Itsghcalls are answered by a shim whose unanswered call is a loud harness fault, not a silent refusal — empty output reads as "no label" everywhere in this step, so a broken shim would have looked exactly like the gate working.The static arm's detector is asserted against its own false positive in the same call: the workflow's comment names the banned expression, and a whole-file scan would have "fixed" that by deleting the sentence recording this item. That is the same trap
_GH_ADD_LABELand_gating_textalready record one module over.COLLISION — read this before merging
claim.ps1 -Listshows a peer Builder holding BACKLOG #1423, "review-gate fail-open", in worktreeagent-acdb9222ba84254fb. That item is not onorigin/mainand I cannot see its diff, but the title says it is very likely in the same file —.github/workflows/review-gate.yml— and possibly intests/test_merge_gate_controls.pyas well. Land one, then rebase the other. I claimed #1417 with that collision recorded in the claim note.Lesser overlap:
docs/BACKLOG.md. Peer Builders are re-scoring rows concurrently (2b8bccb43scored 73 items today). I changed the #1417 banner line, its "Build state" table cell, and added a "What landed" section at the foot of the item. I deliberately left the dated 2026-09-03 scoring paragraph verbatim, because it is a measurement of a past state and rewriting it invites a conflict; the banner directly above it now carries the correction and points at the new section.Checks
Interpreter identity verified first, and it resolves inside this worktree:
pytest tests/test_merge_gate_controls.py tests/test_negative_controls.pypytest tests/test_security_posture.py tests/test_required_contexts.py tests/test_ci_docs_only_detector.pyruff check .ruff format --check .mypy messagefoundry(strict)scripts/docs/backlog_status_check.pyscripts/docs/link_check.pyscripts/docs/backlog_citation_check.pyscripts/docs/claude_section_check.pyactionlintNot run, and CI is the authority. The full
pytest tests/suite: it takes over ten minutes here andtests/test_connscale_smoke.pyfails under worktree contention from the known fixed-port collision (BACKLOG #1014). Hosted-runner-only legs —windows-service-smokeand the rest — a Builder never sees. Read those on the PR./simplifywas not invoked as a skill. The changed code is a shell script inside a workflow and one test module, not engine code; I did the reduction pass by hand instead. Saying so rather than implying the skill ran.Housekeeping
worktree-agent-a01b6380fa19e85ea. One commit, one layer.reviewedlabel to my own pull request after my last push, so the record is honest about it: this has not been read by an independent party. The label records that a step happened, which is exactly what the gate's own header says it means.🤖 Generated with Claude Code