Repository navigation
fix(pr-merge): carry a hold-path lift over a later base merge - #10596
Conversation
A trusted person's lift of a hold path (an approving review, or their own removal of the human-gated label while that head was the branch's head) now covers a later head whose every commit since is a merge and whose hold-path net change is within the lifted head's. One rule, baseMergeCarry (merge-holds.mjs) and base_merge_carry (babysit_merge_policy.py), now carries both this lift and the owner's cost decision; the cost carry also requires every commit since to be a merge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as a lift On #10224 the base merge's push dismissed the owner's approval as stale, which would otherwise take the lift back. A review_dismissed event with no message whose dismissal commit the branch was pushed to keeps the approval of the commit it reviewed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th lift Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 44251530bf
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
claude-review reviewed No blocking or advisory findings. I read the whole diff ( The PR has no closing issue, so there are no acceptance criteria to check. It touches trusted paths (
Notes, not counted as findings:
|
|
claude-security-review reviewed Security review: PR #10596, carry a hold-path lift over a later base mergecritical-findings: 0 Scope read. I read the whole diff and the PR context. The trusted-path files in it are What the change does. A trusted person's approving review of an earlier head can now lift the hold-path hold and the trusted-actor-list widening hold on a later head. The cost decision carries by the same rule. This weakens a merge hold, so I checked what still stops a bypass. Checks that held
One finding, SUGGESTION. See the inline comment on the mode-flip gap in Other checks
|
…ed base merges Apply the owner's decision on #10596 and the security review's findings: - Only an approving review lifts a gate or trust hold path. A label removal no longer lifts it, and a dismissed approval never counts. Remove headAt, pushMoves, humanGatedAt and the stale-dismissal rule (JS and Python); unranHead and withActorContents return to main's form. - A carried approval lifts only the hold path and the trusted-actor list widening, never the verifier, issue-link or pr-verify FAIL holds. - The base-merge carry requires every merged parent but the first to be in the base branch (compare <parent>...<base> ahead or identical), and no file in the new net change that the approved head's lacks unless the base changed it between the merge bases (mergedParents, newFiles, baseFacts). The cost carry and ci-status's cost step use the same check. - Python carried_lifters lifts nothing for a head in a fork. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e same Address the re-review of #10596 at cbb6854: - The hold-path carry now requires sameNetChange over every file, the rule the cost carry uses, so a merge that rewrites a non-hold file the approved head changed, or adds a file the base touched, carries nothing. Delete netChangeWithin, newFiles and the merge-base read (JS and Python). - Resolve the base branch once by its full ref (baseTip, base_tip: git/ref/heads/<base>) and name its tip commit in every carry compare, in liftersOf, the cost carry and ci-status's cost step, so no tag of the same name stands in for it. - liftersOf reads the pull request itself and lifts nothing for a head that moved or is in a fork; enable-auto-merge passes no fixed base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…head in Python Address the third security review of #10596 at 5b4c191: - noticedHead and _noticed_head compare each lane run's head against the base branch's tip commit, resolved once by its full ref (baseTip, base_tip), so no compare names the branch. - Python carried_lifters re-reads the pull request and carries nothing unless its head is the one judged, as JS liftersOf does. - A hold-path carry requires the head compare to list as many files as the pull request's changed_files, so a truncated compare fails closed. - Note in sameNetChange that a mode-only change is not visible in a compare and can carry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No related issue: owner re-lift churn measured on #10224
Summary
On #10224, a lanes App pull request that changes a hold path, the owner lifted the hold on head
f3de238112at 23:26Z by removingneeds-humanand approved it at 23:31Z. At 23:28Z the pr-unblock resolve-conflicts lane merged main into the branch (headbad82b1b56), and at 23:45Z enable-auto-merge labeled itneeds-humanagain: "it changes .github/actions/resolve-config/live-config.test.mjs, a hold path".A hold path was lifted only by a trusted person's approving review of the exact head (
merge-holds.mjspathLifters,babysit_merge_policy.path_lifters), so any base merge after the approval sent the pull request back to the owner.Fix
The owner's decision on this pull request: on gate and trust hold paths, only an approving review lifts the hold, bound to its commit, and carried only across a verified merge of the base. A label removal lifts no hold-path hold, and a dismissed approval never counts. ci-status's separate review-lane hold rule, where a label removal approves, is unchanged.
Which approvals count (
liftsOf,lifts_of): each trusted person's newest review that approves, requests changes or was dismissed; it lifts only when it approves, and then its commit. A dismissed approval never counts, whoever dismissed it.The carry (
baseMergeCarry,base_merge_carry): the base branch is read once by its full ref (baseTip,base_tip:GET /repos/{repo}/git/ref/heads/{base}), and every compare names its tip commit, so no tag of the same name stands in for it. An approval of head H covers head H' when both hold, read from the compares<tip>...Hand<tip>...H':baseMergesOnlyovermergedParents;baseParentsreads eachcompare/<parent>...<tip>,aheadoridentical), and H is among H''s commits. A crafted merge commit as a second parent is not a base commit.sameNetChange: same files, status,previous_filenameand patch). A merge that writes any change of its own, to a hold path or any other file, carries nothing. An honest merge after the base changed one of the pull request's files also carries nothing, and needs a new approval.The owner's cost decision carries through the same function, so the cost hold reads the base the same way.
What a carried approval lifts: the hold path and the trusted-actor list widening, nothing else. Only an approving review of the exact head lifts the verifier, issue-link and pr-verify FAIL holds.
Where the rule runs:
merge-holds.mjsmain) callsliftersOfonly when a hold path or widening holds and no approval of the head is in the discussion file.liftersOfreads the pull request itself and lifts nothing for a head that moved or is in a fork, then reads the base ref, the reviews and the compares with the step'sGH_TOKEN.liftersOf, with the pull request's own base rather than a fixedmain, so a pull request with a carried approval is armed instead of labeled again.lane_risk_refusal,carried_lifters) dropshigh-path:reasons on the same approvals, and lifts nothing for a head in a fork.mergedParents), and calls the base'sbaseMergeCarry. Until this pull request merges, a base without it keeps the hold.Limit under the current ruleset. Ruleset 24475076 on
mainsetsdismiss_stale_reviews_on_push: true, so the push of a base merge dismisses every approval of the earlier head, and a dismissed approval never counts. On #10224 the owner's 23:31:57Z approval off3de238112reads asDISMISSED. While that setting holds, an approval does not carry over a base merge in this repository, and the #10224 churn stays; the carry applies where stale approvals are not dismissed. The cost-decision carry does not depend on reviews and is unaffected.Residual: mode-only changes. The compare API reports no file mode, so a merge that only flips a file's mode (making a script executable, say) reads as the same net change and carries an approval. Closing this needs tree reads, which this change does not add.
Truncated compares. A hold-path carry also requires the head compare to list as many files as the pull request's
changed_files(read with the pull requestliftersOfandcarried_liftersalready re-read), so a compare GitHub cut short fails closed. That GitHub truncates a compare below its 300-file cap is UNVERIFIED. The cost carry does not havechanged_filesin hand without another read, so it relies onsameNetChange's 300-file cap alone.The lane-change vet in the cost carry (
noticedHead,_noticed_head) also compares against the base's tip commit, resolved once, never the branch name.Security reviews of earlier versions found that they counted a label removal at the exact head as an approval, bound a removal to a head pushed while the person was reviewing, checked only a merge's parent count, compared only hold-path files (so a merge could rewrite
scripts/helper.shand still carry), admitted any file the base had touched, named the base by branch in every compare, and lacked the fork check in Python. The owner's decision removes the label-removal path and the stale-dismissal rule; the parent check, the whole-net-change rule, the tip-commit base and the fork check are in.#10224's own shape no longer carries under the whole-net-change rule. Replayed read-only against main's tip when enable-auto-merge relabeled it (23:45Z,
0218059955): the net change atbad82b1b56differs fromf3de238112's in two files outside the hold paths (.github/actions/collect-base-activity/collect-base-activity.test.mjsandplugins/code-tidying/.claude-plugin/plugin.json), sosameNetChangeis false. It fails closed and needs a new approval, as decided. Combined with the ruleset limit above, this change does not remove the #10224 churn; it closes the base-merge hole safely.Verification
Adversarial cases. Every one holds, in JS (
merge-holds.test.mjsthroughmain), and in JS and Python together (test_merge_holds_sync.py):scripts/helper.sh, which the approved head already changedEach fix's tests fail against the version before it: under
cbb68549, thescripts/helper.shrewrite and the added base-touched file carry (baseMergeCarryreturns true), and the tip-commit path assertions fail. The positive cases assert that the base is read once bygit/ref/heads/mainand every carry compare names the tip commit. No reader reads issue events, push history or the tracker config for a hold-path lift.Positive cases carry: an approval of an earlier head followed by a base merge, with or without a non-hold file in the net change that the merge leaves alone.
Suites, before the merge of main:
merge-holds.test.mjs: 46 pass.enable-auto-merge.test.mjs: 143 pass.scripts/tests: 3332 passed.ci-hold-cost-decision.test.sh: 157.ci-hold-human-gated: 49.ci-hold-lifted-by: 35.ci-hold-merge-group: 67.ci-lanes-pr-hold: 220.Related
Follow-up, outside this change:
babysit_verify_evidence.pyreads the trusted-actor list by the base branch's name (around line 1400), with the same tag-shadowing exposure this change closes for compares.feat(pr-pipeline): tidy a small single-area change inside resolve-issue before its one push #10224: the measured churn.
fix(pr-merge): keep a cost hold when a newer lane run was deleted #10515 (merged): the cost hold's push-history check.
This changes gate files (
merge-holds.mjs,enable-auto-merge.mjs,pr-require-checks.yml,babysit_merge_policy.py), so the security lane may hold it for a person.The cost carry now also requires every commit since the decided head to be a merge of commits the base holds, so a rebase no longer carries a cost decision. This only tightens that hold.
🤖 Generated with Claude Code