Skip to content

fix(pr-merge): take a cost decision's decided head from the review lane's check-run annotation - #10357

Merged
kyle-sexton merged 14 commits into
mainfrom
fix/9635-cost-decision-head-from-annotation
Oct 27, 2026
Merged

kyle-sexton merged 14 commits into
mainfrom
fix/9635-cost-decision-head-from-annotation

Conversation

@kyle-sexton

Copy link
Copy Markdown
Member

Refs: #9635

This PR covers the merge-side half of #9635. The ci-workflows partner covers hold.cjs, closes the issue, and merges last.

Summary

The cost-decision readers took a decision's decided head from the newest unedited github-actions[bot] claude-lane-hold ... criteria=cost comment before it. Any workflow running on GITHUB_TOKEN posts as github-actions[bot], so the same-head shortcut rested on a comment author login. All three merge-side readers now read the decided head from the review lanes' own check-run notices, the ones ci-status already vets.

Fix

The rule is the same in all three readers:

  • Where the head comes from. Read every pull_request run of pr-review-hosted.yml and pr-review-security-hosted.yml on the head branch that started before the decision.
  • Vet each run's head. Each head must change neither review-lane workflow and must list fewer than 300 files against the base. Otherwise nothing carries.
  • Which notices count. In each run, read the status job (pr-review / claude-review-status, pr-review-security / security-review) when it finished before the decision. Count its claude-lane-hold ... criteria= notices that include cost, and only those whose sha= is the run's own head.
  • Which notice decides. The newest one. If two heads were noticed at that time, nothing carries.

Changes per reader:

  • ci-status (.github/workflows/pr-require-checks.yml, the cost-hold step):
    • The runs loop now also reads runs that started before the decision, and keeps each notice's sha and its run's head.
    • jq picks the decided head; carries compares it.
    • The window's holds are filtered from the same notices.
    • The comment-marker read and its edit and deletion checks are removed. The base module's costMarker/deletedSince are no longer called; only sameNetChange and refsIssue are.
  • merge-holds.mjs:
    • New REVIEW_LANES, laneChange, costNotices, decidedHead, and noticedHead, which carries calls.
    • costMarker, deletedSince and COMMENT_DELETED_QUERY are removed.
    • PULL_OPENED_QUERY gains headRefName.
    • costDecision takes an optional actions reader (default: github).
  • enable-auto-merge.mjs: builds that actions reader from BABYSIT_VERIFY_GH_TOKEN. That is the job token, which holds actions: read and checks: read; the lanes App token holds neither.
  • babysit_merge_policy.py:
    • REVIEW_LANES, lane_change, cost_notices, decided_head and _noticed_head mirror the JS functions.
    • The lane reads use BABYSIT_VERIFY_GH_TOKEN when it is set.
    • _get and _paginated take an optional env, passed to gh only when set.
    • cost_marker, deleted_since and COMMENT_DELETED are removed.
  • Review-lane callers (second commit): pr-review-hosted.yml and pr-review-security-hosted.yml now grant checks: read on the job that calls pr-review-cli.yml, beside the actions: read they already grant. The ci-workflows partner's status job needs both to read its earlier cost notices, and a called workflow can only narrow its caller's grant. Both scopes are read-only on a job that runs no model. Granting more than the pinned reusable (ba7af78) requests changes nothing until the pin moves.
  • The merge-side readers need no new scope: the enable-auto-merge and merge jobs in pr-merge.yml and the ci-status job in pr-require-checks.yml already hold actions: read/checks: read.

Residual: someone who can delete workflow runs could delete the newest lane run and promote an older notice, the same way deleting a comment could promote an older marker before. Nothing detects run deletion. That needs actions: write.

Verification

  • scripts/ci-hold-cost-decision.test.sh: 138 pass, 0 fail.
    • Rewritten so notices decide the head.
    • New cases: a notice naming another head than its run's, a non-status job, the other lane's job name in this workflow, the security lane's status job, two heads at one time, a hold comment with no notice, a newer hold comment naming another head (it changes nothing), a pre-decision run whose head changes a review-lane workflow, and a run started after the decision.
  • node --test .github/scripts/pr-merge/merge-holds.test.mjs: 29 pass. New cases cover laneChange, costNotices, decidedHead, the vet, and a separate actions reader.
  • node --test .github/scripts/pr-merge/enable-auto-merge.test.mjs: 99 pass.
  • python3 -m unittest tests.test_babysit_merge_cost tests.test_merge_holds_sync (babysit-prs scripts): 32 pass. The JS and Python readers agree on 21 carry cases.
  • scripts/affected-tests.sh --run --base origin/main:
    • The babysit unittest suite passed: 2978 tests, OK.
    • Two suites fail locally because PyYAML is missing: ci-test-node-stop-commands.test.sh and run-hygiene-gate.test.sh.
    • The 77 node suites that runner skips were run by hand: 1144 of 1147 pass. The 3 failures are resolve-config suites that cannot load ajv in this worktree (ERR_MODULE_NOT_FOUND).
  • actionlint .github/workflows/pr-require-checks.yml: clean. scripts/run-ruff.sh check: clean.
  • After the caller-grant commit: actionlint on both callers clean; node --test .github/actions/resolve-config/*.test.mjs (with npm ci in that folder, which fixes the earlier ajv load failure): 701 pass, 0 fail, including a new test pinning each caller's review-job scopes; the pr-merge, pr-unblock, pr-verify, check-* and reconcile node suites: 1750 pass; the eight shell suites that read the callers (ci-review-lanes, ci-hold-cost-decision, ci-rerun-ci-status, workflow-self-paths, read-runner-dispatcher-gate, ci-review-plugin-source, post-merge-demotion-alert, merge-base/prepare): all exit 0.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 5 commits October 26, 2026 00:01
…ne's check-run annotation

The cost-decision readers took the decided head from the newest
github-actions[bot] `claude-lane-hold ... criteria=cost` comment before
the decision. Any workflow on GITHUB_TOKEN posts as github-actions[bot],
so the head rested on a comment author login.

All three merge-side readers (ci-status's cost-hold step,
merge-holds.mjs costDecision, babysit_merge_policy.cost_decision) now
read it from the review lanes' status-job notices instead: the newest
cost notice recorded before the decision, only where its sha= is its own
run's head, from runs vetted not to change either review-lane workflow.
Two heads at the newest time decide nothing. The comment read, and the
edit and deletion checks that guarded it, are removed.

Refs: #9635

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ion reads

The ci-workflows partner change has pr-review-cli.yml's status job read
the lane's earlier status-job notices, which needs actions: read and
checks: read. A called workflow can only narrow its caller's grant, so
both callers now grant checks: read beside actions: read. The scope is
read-only, and the job that uses it runs no model. callers.test.mjs pins
the review job's four scopes in both callers.

Refs: #9635

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-head-from-annotation

# Conflicts:
#	.github/scripts/pr-merge/enable-auto-merge.mjs
#	.github/scripts/pr-merge/enable-auto-merge.test.mjs
#	.github/scripts/pr-merge/merge-holds.test.mjs
#	plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py
…n-head-from-annotation

# Conflicts:
#	.github/scripts/pr-merge/merge-holds.test.mjs
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T21:01:12.307076Z 24ffe50 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed 49591bd72df0651138b52b5df0a07808734f56bc: 0 finding(s), 0 as inline comments.

No findings. I read the full diff (.claude-lane/pr.diff) and the PR context, and checked the cost-hold step against the head file. I ran no tests or tools. The test counts in the PR body are the author's claims and I did not confirm them.

What I checked

  • Three readers agree. The ci-status jq (pr-require-checks.yml), merge-holds.mjs (noticedHead, laneChange, costNotices, decidedHead) and babysit_merge_policy.py (_noticed_head, lane_change, cost_notices, decided_head) apply the same rule. All three:
    • read lane runs that started before the decision, from the same repository, and vet each run's head;
    • count a notice only if it is a notice-level annotation with cost in its criteria, from the status job, finished before the decision, and naming its own run's head;
    • take the newest notice, and carry nothing if two heads share that time;
    • fail closed on any unreadable run, job, annotation or compare. ci-status calls keep, the JS and Python return null or False, and the vet refuses a head with a workflow change, 300 or more files, or no file list.
  • Token plumbing. The enable-auto-merge arm step in pr-merge.yml sets BABYSIT_VERIFY_GH_TOKEN: ${{ github.token }}. The job holds actions: read and checks: read. The new actions reader in enable-auto-merge.mjs uses that token only when github was not injected. The Python lane reads pass it through env_overrides only when it is set.
  • Comment path removed. Only the github-actions[bot] hold-comment path is gone, with its marker, edit and deletion reads. The deleted-comment and comments reads that ci-status still uses for the verdict stay. The decision comment's own edit check stays.
  • Caller grants. checks: read on pr-review-hosted.yml and pr-review-security-hosted.yml is read-only and sits on a job that runs no model. The new callers.test.mjs case pins it.
  • Tests. The JS, Python and shell tests are rewritten to the notice model and cover the forgery cases the PR describes. The Python and JS sync test keeps the two readers aligned.
  • Residual risk the PR names. Deleting the newest lane run could promote an older notice, and nothing detects run deletion. The PR states it, and the old comment path had the same weakness. I judged it acceptable and did not count it.

Cost: none. The change adds read-only API calls: a full run listing for the branch on each decision check, and a compare per distinct head. Those draw on GitHub API rate limits, not billing.

Closing issue: Refs: #9635 only, so no acceptance criteria apply. The PR says the ci-workflows partner closes it.

Manifests: none changed. The changelog fragment is bump: patch for source-control and matches a fix to babysit_merge_policy.py.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-security-review: 1 inline finding(s) on 24ffe50f96b5377dca8a4aa63290e68d241a3318; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/merge-holds.mjs
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 49591bd72df0651138b52b5df0a07808734f56bc: 0 finding(s), 0 as inline comments.

No security findings. I read the whole diff and the code it gates (all 13 files). Three changed paths are trust controls: merge-holds.mjs, enable-auto-merge.mjs and pr-require-checks.yml. babysit_merge_policy.py is under babysit-prs/scripts/, which is also on the merge-hold list. I judged each one by its code, not by the PR text. The change narrows the merge hold and does not weaken it.

critical-findings: 0

What changed. The cost-decision reader no longer takes the decided head from a github-actions[bot] hold comment. Any GITHUB_TOKEN workflow can post as that login. The three readers (JS, Python, and the ci-status shell step) now take it from the review lanes' own status-job check-run notices.

Why it does not widen the hold:

  • Run filter. A run counts only if its head repository is this repository and it started before the decision.
  • Head vet. Every run's head is vetted against the base. If it changes either review-lane workflow, or the compare lists 300 or more files or returns no file list, nothing carries. In ci-status, the shell step does this vet for every counted run and returns keep, so the hold stays.
  • Notice filter. A notice counts only from the lane's status job. It must be a notice annotation that includes cost, name its own run's head, and come from a job that finished before the decision.
  • Ties and gaps. The newest notice wins, and two heads at the same time carry nothing. No notice, an unusable base or branch name, or any failed read carries nothing, so these fail closed.
  • After the decision. A push after the decision still only carries when sameNetChange holds. Notices from jobs finished after the decision are ignored, so a later head cannot decide for itself.
  • Token. The lane reads use BABYSIT_VERIFY_GH_TOKEN, the job token, which pr-merge.yml already sets on the relevant steps. If it is missing, the lanes App token lacks actions: read and checks: read, so the reads fail and nothing carries.
  • Removed checks. The hold-comment edit and deletion checks go away with the comment read. The comment was the only thing they protected. The verdict step still checks for deleted comments and escalation comments.
  • Mirrors. The JS and Python versions apply the same rules. The Python version rejects an unusable base earlier than before, which is stricter.

Manifest items: none touched, so no manifest <field> lines.

Permission note (not a finding). The two caller workflows gain checks: read on a job that runs no model. The PR says the pinned reusable (ba7af78) requests nothing more, so it changes nothing until the pin moves. It is read-only and applies to GITHUB_TOKEN, not to the lanes App token scope, so it is not a trust-control widening. The workflow-permission angle is zizmor's lane.

Residual risk, already named in the PR. Someone who can delete workflow runs could delete the newest lane run and promote an older notice. The earlier comment design had the same exposure through comment deletion. Closing it needs actions: write. It needs run-delete rights, so I did not count it.

Cost: none. The change only adds read scopes and extra read-only API calls, which draw on the rate limit and not on money.

kyle-sexton and others added 2 commits October 26, 2026 18:05
… runs

The workflow-runs branch= filter matches head_branch alone, so a fork pull
request on a same-named branch could post the cost notice that decides
another pull request's head. merge-holds.mjs noticedHead, babysit
_noticed_head and the ci-status cost-hold step now skip any lane run whose
head_repository.full_name is not this repository, before the vet, so a
foreign run neither decides the head nor holds the step. run.pull_requests
is not used: GitHub documents it as open pull requests matching the run's
head branch or sha, not the one that triggered the run, and it is empty
for closed pull requests and fork runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-security-review: 1 inline finding(s) on d73179415965b99edfd08e7610d9e0c701fe1797; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/merge-holds.mjs
kyle-sexton and others added 2 commits October 26, 2026 23:13
…ne run

noticedHead skips a lane run whose head_repository is another repository,
so the enable-auto-merge fixture's run carries this repository, as the
merge-holds fixtures do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton kyle-sexton added the do-not-merge Hard merge gate: do not merge while applied. label Oct 27, 2026
@melodic-automation-lanes melodic-automation-lanes Bot added the needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this. label Oct 27, 2026
@melodic-automation-lanes

Copy link
Copy Markdown
Contributor

The pr-unblock lane stopped on this pull request and needs a person: the merge stages a change under .github/workflows/, which the lane does not push.

  • Reason: workflow-staged
  • Resume key: d73179415965b99edfd08e7610d9e0c701fe1797 (the head the lane stopped on)

To resume, deal with the reason above, remove the needs-human label (and do-not-merge, if set), then dispatch the pr-unblock workflow with pr-number=10357 and activity=resolve-conflicts:

gh workflow run pr-unblock.yml -R melodic-software/claude-code-plugins -f pr-number=10357 -f activity=resolve-conflicts

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-security-review: 1 inline finding(s) on a7024686d1762537a71edfff40ff2beedf8d61c9; the summary is in this lane's pull request comment.

claude-lane: claude-security-review

Comment thread .github/scripts/pr-merge/merge-holds.mjs
# Conflicts:
#	scripts/ci-hold-cost-decision.test.sh
@melodic-automation-lanes

Copy link
Copy Markdown
Contributor

The pr-unblock lane escalated this pull request as workflow-staged, and that no longer holds at 8bb77cf159e98c2c75e26034a39575b918f12a61: main changes nothing under .github/workflows/ since its merge base with 8bb77cf. pr-merge removed needs-human, so the lanes run on it again. To keep a person on it, add needs-human back: a label a person adds is never removed.

@melodic-automation-lanes melodic-automation-lanes Bot removed the needs-human Human-in-the-loop required; autonomous sessions must not resolve items carrying this. label Oct 27, 2026
# Conflicts:
#	.github/scripts/pr-merge/enable-auto-merge.mjs
#	plugins/source-control/skills/babysit-prs/scripts/tests/test_merge_holds_sync.py
@kyle-sexton kyle-sexton removed the do-not-merge Hard merge gate: do not merge while applied. label Oct 27, 2026
@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 27, 2026
@melodic-automation-lanes

Copy link
Copy Markdown
Contributor

Unattended merge: risk HIGH at head 49591bd72df0651138b52b5df0a07808734f56bc. Only LOW, or MED on class, size and reported review-finding reasons alone, merges without a human. A floor path holds it: a human merges it in person, never an agent or a lane.

HIGH high-path:.github/actions/resolve-config/**: .github/actions/resolve-config/callers.test.mjs
HIGH high-path:.github/scripts/pr-merge/enable-auto-merge.mjs: .github/scripts/pr-merge/enable-auto-merge.mjs
HIGH high-path:.github/scripts/pr-merge/merge-holds.mjs: .github/scripts/pr-merge/merge-holds.mjs
HIGH high-path:.github/workflows/pr-require-checks.yml: .github/workflows/pr-require-checks.yml
HIGH high-path:plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py: plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py
MED evidence-unbound:claude-review: not bound: lane-source-changed
MED evidence-unbound:claude-security-review: not bound: lane-source-changed

@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 6e004b0 Oct 27, 2026
67 of 68 checks passed
@kyle-sexton
kyle-sexton deleted the fix/9635-cost-decision-head-from-annotation branch October 27, 2026 17:27
@github-actions

Copy link
Copy Markdown
Contributor

Post-merge mutation testing

Killed 0, survived 0, timeout 0, no coverage 0, invalid 0, not run 8.
Covered-code score n/a; mutation score n/a.

File Killed Survived Timeout No coverage Invalid Not run
.github/scripts/pr-merge/enable-auto-merge.mjs 0 0 0 0 0 2
.github/scripts/pr-merge/merge-holds.mjs 0 0 0 0 0 3
plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py 0 0 0 0 0 3

The run's summary has the full report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant