Repository navigation
fix(pr-merge): count a stale-dismissed approval and carry it over an exact base merge - #10671
Conversation
…exact base merge The owner's decision of 2026-10-28 on the #10596 follow-up: - An approval GitHub dismissed as stale on a push still counts, bound to its own commit and only through the carry: the issue timeline's review_dismissed event has no message, a Bot actor and a dismissal commit among those pushed since the approved head (autoDismissed, auto_dismissed). A dismissal with a message, by a person, or by a person's push still revokes it. - When the net change differs, the approval carries when the merges brought exactly the base's changes: compare H...H' equals, file for file, compare M...M' between the merge bases of H and H' with the base's tip (exactBaseCarry, approvalCarry and their Python twins). sameNetChange stays the fast path; a truncated list fails closed. The cost-decision carry is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…merge 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. |
|
claude-security-review reviewed Security review: PR #10671 (exact base-merge carry and stale-dismissal restore)critical-findings: 0 I found no security issues. This change loosens a merge hold, so I checked it for a path where a pull request author gets through content a trusted person never approved. Scope. I read the full diff of Weakened hold: a stale-dismissed approval now counts.
Weakened hold: the exact base-merge carry.
The first three together mean Other controls I checked.
The base still has the repo's required reviews and the trust-root ruleset, and this change adds no workflow permission. The PR states that timeline reads fit the existing Residuals, all documented in the PR.
This is a hold-path file change under the owner's 2026-10-28 decision, so a person will still see it. That is not a finding. |
|
claude-review reviewed No blocking or advisory findings in the code-review lane for this head. I read the whole diff and the pull request context. I did not run the tests; the pass counts in the description are the author's claims and I did not verify them. What I checked
Remaining risks (not findings)
Spend: none beyond normal CI runs; no paid plan or billing setting is touched. |
… the default branch The independent security review of #10671: - The exact path reads the recursive trees of H' and M' and requires each brought file's mode at H' to equal M''s (a removed or renamed-away path in neither); a truncated or unreadable tree fails closed. - The carry runs only when the base is the repository's default branch. - Python's pull re-read also refuses a head in a fork, as JS does. - Document the compare-completeness assumption and reword the dismissal rule to match the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Post-merge mutation testingKilled 0, survived 0, timeout 0, no coverage 0, invalid 0, not run 8.
The run's summary has the full report. |
Refs: #10596
Summary
Follow-up to #10596. On gate and trust hold paths only a trusted person's approving review lifts the hold, bound to its commit, and it carries to a later head only over merges of the base. Two facts made that carry useless:
dismiss_stale_reviews_on_push: true, so the base merge's push dismisses the approval, and a dismissed approval never counted.sameNetChangefails whenever main touched a file the pull request also changes, as in the feat(pr-pipeline): tidy a small single-area change inside resolve-issue before its one push #10224 replay (f3de238112tobad82b1b56).The owner decided on 2026-10-28 to build both parts below. The cost-decision carry is unchanged.
Fix
(a) GitHub's automatic stale dismissal still counts (
autoDismissed/auto_dismissed,liftsOf/lifts_of).A trusted person's review whose REST state is
DISMISSEDcounts as an approval of its owncommit_idonly when the pull request's issue timeline has exactly onereview_dismissedevent for that review id, and that event has all of these:dismissed_review.stateofapproved;dismissal_messagestrictlynull(an empty string or a missing field revokes);Bot(the pushing App);dismissal_commit_id.A restored approval lifts nothing by itself:
liftersOf/carried_lifters, and never reachespathLifters;approvalCarryalso requires its dismissal commit to be one of the commits the current head adds since the approved head, so the push the carry checks is the push that dismissed it.The Bot actor is what tells GitHub's dismissal from a person's. A dismissal whose actor is a
Userrevokes: a person's own dismissal, or the stale dismissal their push caused (a person's push revokes only when it is the dismissing push). The null message is a second filter: people are already excluded by actor type, and that an App's deliberate dismissal always carries a message is UNVERIFIED (the dismissal API documentsmessageas required). A dismissal with a message, a second dismissal event for the same review, and a later review by the same person also revoke. Approving H, pushing X, then force-pushing back to H leaves no range, so it lifts nothing.The timeline is read only when a trusted person has a dismissed review. A timeline that does not read restores nothing. GitHub lists
GET /repos/{owner}/{repo}/issues/{issue_number}/timelineunder the "Pull requests" read permission as well as "Issues" (docs.github.com, "Permissions required for GitHub Apps"). ci-status'spull-requests: writetoken and the lane App token's pull-requests write both cover it, so no workflow permission changes.(b) The exact carry (
exactBaseCarry/exact_base_carry,approvalCarry/approval_carry).baseMergesOnlystill requires every commit since H to be a merge whose non-first parents the base holds. Then either check carries:sameNetChange(then, now).merge_base_commitof the existing<tip>...Hand<tip>...H'compares, then readsH...H'(what the merges brought) andM...M'(what the base changed between the two merge bases). Both conditions must hold:H...H'isaheadwithbehind_by0, andM...M'isaheadoridenticalwithbehind_by0. Each commit list is read whole (total_commitsmatches).previous_filenameand patch (sameNetChangereused, so either list at 300 files, or any file without a patch, fails closed).git/trees/<sha>?recursive=1, read once each,truncatedmust befalse) give each brought file one mode, and a removed file or a renamed file's old path is in neither tree (sameModes/_same_modes). This closes the mode residual on this path and cross-checks that every brought path exists in H'.The carry, fast path included, runs only when the pull request's base is the repository's default branch: another base may be non-linear, with criss-cross merge bases. Python's pull re-read now also refuses a head in a fork, as JS does.
Assumption, not checked: the exact path assumes GitHub omits no file from a compare's file list when it lists fewer than 300 files. Nothing reads a second listing to cross-check that; the tree read only confirms each listed path exists in H'.
What the exact path rejects:
baseMergeCarrykeeps its arity and meaning, because the cost-hold bash step inpr-require-checks.ymlcalls it positionally from the base's module.Cost carry left unchanged. Moving it would mean changing three readers in lockstep (JS
carries, Python_carries, and the bashcarries()step in a security gate). A push does not dismiss a cost decision, so problem (1) does not apply to it.Security reasoning.
M...M'is base content only, and the two-way equality admits nothing else. Patches include hunk headers and context, so a base hunk lands only where it applied cleanly.total_commitsmismatch, missing patch, truncated tree), fork head, moved head, missing merge base or non-default base fails closed. The mode residual remains only on the same-net-change fast path, which reads no trees.Verification
Failing first:
7a18685f2c, three new JS tests inmerge-holds.test.mjsfailed: the stale-dismissal carry, feat(pr-pipeline): tidy a small single-area change inside resolve-issue before its one push #10224's shape, and the pure-function test.test_a_stale_dismissed_approval_carries,test_10224s_shape_carries_when_the_merges_brought_exactly_mains_changesand the parity testTheCarriedLiftIsTheGatesfailed.merge-holds.mjs.behind_by == 0survivor got its own case).Covered, in JS and Python:
Every pre-existing adversarial case now carries consistent
H...H'andM...M'data, so it fails for its own reason and not for missing data.Suites, after merging main (
417bdd5639):merge-holds.test.mjs: 52 pass.enable-auto-merge.test.mjs: 144 pass.scripts/tests: 3344 passed.ci-hold-cost-decision: 157.ci-hold-human-gated: 49.ci-hold-lifted-by: 35.ci-hold-merge-group: 67.ci-hold-pr-unblock-status: 27.ci-lanes-pr-hold: 231.#10224 replay (read-only, cached compares; main at
0218059955, Hf3de238112, H'bad82b1b56):bad82b1b56, the lanes App's merge push.kyle-sexton(a person's own non-merge push,1f6d28bed7) is treated as revoking.H...H'andM...M'each list exactly 300 files, the compare cap; main moved 17 commits.collect-base-activity.test.mjsdiffers in the hunk header (-128vs-125), andplugin.jsondiffers in a context line (the pull request's description change shows as context).Related
merge-holds.mjs,babysit_merge_policy.py), so the security lane may hold it for a person.🤖 Generated with Claude Code