diff --git a/.changes/source-control/fix-postmerge-green-run-on-main-9466-6b2f8c04.md b/.changes/source-control/fix-postmerge-green-run-on-main-9466-6b2f8c04.md new file mode 100644 index 0000000000..25eca6d811 --- /dev/null +++ b/.changes/source-control/fix-postmerge-green-run-on-main-9466-6b2f8c04.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The post-merge check takes the newest green full run for a red run's affected set (`_Context.affected`) and the newest completed run for its baseline (`_Context._baseline`). It now picks only a run whose head commit is the branch's tip or an ancestor of `refs/heads/` (`_Context.on_branch`, through `_Context.contains`). The same check now filters the scheduled, dispatched and canary runs a merge is judged on, the runs read by commit to prove a listing incomplete, and the lane re-run probe, so a red run on such a tag can no longer fail a merge. The promotion evidence (`publish-promotion-evidence.mjs`) filters its post-merge runs the same way. Before, it trusted any run that reported `head_branch: main`, and a run on a tag named `main` reports that too. Such a run could check out a writer's own commit and run its plan script, or narrow the affected set so that a red main run read as unaffected and filed no demotion. diff --git a/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md b/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md new file mode 100644 index 0000000000..a5b500a9cd --- /dev/null +++ b/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- The post-merge check (`babysit_postmerge.py`) recognizes a native auto-merge of the agent identity's pull request: the identity opened it from a branch of this repository, and the newest auto-merge enable or disable event is the identity's enable, made no later than the merge-queue enqueue. Such a merge posts no `babysit-merge-pin` comment, so it no longer waits for a pin read-back and fails it at the dispatch deadline, which filed a demotion record and held pr-merge for owner and Dependabot pull requests. It is still the identity's merge, so post-merge-dispatch starts its full run, and it verifies on the dispatch and canary runs alone; a red full run or canary after it still fails it. The auto-merge events come from a second aliased `timelineItems` connection in the same merged-pull-request query, and a native merge skips the pin read, so no read is added. The promotion evidence (`publish-promotion-evidence.mjs`) applies the same rule, requiring one lane identity as author, enqueuer and armer, and refuses to publish when that identity's auto-merge events cannot be read, so its demotion counts still match. A gate merge keeps its read-back: the gate disarms an armed auto-merge before it pins, and an arm made after the enqueue does not count. diff --git a/plugins/source-control/skills/babysit-loop/scripts/fixtures/publish-promotion-evidence/github-fake.mjs b/plugins/source-control/skills/babysit-loop/scripts/fixtures/publish-promotion-evidence/github-fake.mjs index 9862d3ed2d..0a94d78f78 100644 --- a/plugins/source-control/skills/babysit-loop/scripts/fixtures/publish-promotion-evidence/github-fake.mjs +++ b/plugins/source-control/skills/babysit-loop/scripts/fixtures/publish-promotion-evidence/github-fake.mjs @@ -46,6 +46,9 @@ export function pr( labels: page1(names), closingIssuesReferences: page1(linked), timelineItems: page1(timeline), + autoMerge: { nodes: [] }, + author: actorOf(LANE), + isCrossRepository: false, ...patch, }, list: { @@ -85,6 +88,10 @@ export const wfRun = (id, event, sha, created, { conclusion = "success", status // `workflowRuns`: {: [run]}; `attempts`: {: {: run}}; `history`: the // default branch's commits oldest first (linear, as a merge-queue branch is), defaulting to the // merge commits in merge order. +// `offBranch`: commits not on the default branch that descend from it, as on a tag named main. +// `onTop`: commits on top of the branch tip but not on the branch (a tag named main one commit +// ahead), which a compare against the tip answers `behind`. `unknownCompare`: commits whose +// compare answers no status. `tip`: what `refs/heads/main` reads, default the last commit. // `diverged`: head SHAs a compare reports as diverged. A PR number not in `pulls` answers with the // recorded NOT_FOUND response. // The lane posts a pin comment on every PR it enqueues and dispatches a full run after every merge @@ -122,7 +129,7 @@ export const issueItem = (number, created, { pull = false } = {}) => ({ created_at: created, pull_request: pull ? { url: `https://api.github.com/repos/${REPO}/pulls/${number}` } : null, }); -export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {}, issueEvents = {}, reviews = {}, issues = {}, workflowRuns: explicitRuns = {}, attempts = {}, history, diverged = [], comments = {}, noDefaultDispatch = false, bumpOnPage2, deletedRuns = [], demotionIssues = [], issueList = [], goneIssues = [], missingIssues = [], discussions = {}, lateRuns = [] }) { +export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {}, issueEvents = {}, reviews = {}, issues = {}, workflowRuns: explicitRuns = {}, attempts = {}, history, diverged = [], comments = {}, noDefaultDispatch = false, bumpOnPage2, deletedRuns = [], demotionIssues = [], issueList = [], goneIssues = [], missingIssues = [], discussions = {}, lateRuns = [], offBranch = [], onTop = [], unknownCompare = [], tip }) { const defaultDispatch = noDefaultDispatch ? [] : pulls.map((p) => wfRun(900000 + p.graph.number, "workflow_dispatch", p.graph.mergeCommit.oid, later(p.graph.mergedAt, 60), { actor: LANE })); const explicitIds = new Set(Object.values(explicitRuns).flat().map((r) => r.id)); const numbered = (runs) => @@ -224,7 +231,14 @@ export function github({ pulls = [], commits = [], checkRuns = {}, statuses = {} return { total_count: Math.min(runs.length, 2500), workflow_runs: runs.slice((n - 1) * size, n * size) }; } if ((m = /^actions\/runs\/(\d+)\/attempts\/(\d+)$/.exec(rest))) return attempts[m[1]][m[2]]; + if (rest === "git/ref/heads/main") return { ref: "refs/heads/main", object: { sha: tip === undefined ? branch.at(-1) : tip } }; if ((m = /^compare\/([0-9a-f]{40})\.\.\.([0-9a-f]{40})\?/.exec(rest))) { + // An off-branch commit (a tag named main) descends from every branch commit and is + // diverged from the tip. + if (unknownCompare.includes(m[1]) || unknownCompare.includes(m[2])) return { ...REC.compare, status: undefined }; + if (onTop.includes(m[1])) return { ...REC.compare, status: "behind", ahead_by: 0, total_commits: 0, commits: [] }; + if (offBranch.includes(m[2]) || onTop.includes(m[2])) return { ...REC.compare, status: "ahead", ahead_by: 1, total_commits: 1, commits: [] }; + if (offBranch.includes(m[1])) return { ...REC.compare, status: "diverged", ahead_by: 0, total_commits: 0, commits: [] }; const [base, head] = [branch.indexOf(m[1]), branch.indexOf(m[2])]; assert.ok(base >= 0 && head >= 0, `compare outside the recorded history: ${rest}`); const status = diverged.includes(m[2]) ? "diverged" : head > base ? "ahead" : head === base ? "identical" : "behind"; diff --git a/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.mjs b/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.mjs index e8b7ba2046..a63d35164e 100755 --- a/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.mjs +++ b/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.mjs @@ -182,11 +182,13 @@ function ms(value, what) { const PULL_QUERY = "query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){" + - "number headRefOid mergedAt lastEditedAt mergeCommit{oid} mergedBy{__typename login} labels(first:100){pageInfo{hasNextPage} nodes{name}} " + + "number headRefOid mergedAt lastEditedAt mergeCommit{oid} mergedBy{__typename login} author{__typename login} isCrossRepository labels(first:100){pageInfo{hasNextPage} nodes{name}} " + "closingIssuesReferences(first:50){pageInfo{hasNextPage} nodes{number labels(first:100){pageInfo{hasNextPage} nodes{name}}}} " + "timelineItems(first:100,itemTypes:[CONNECTED_EVENT,DISCONNECTED_EVENT,ADDED_TO_MERGE_QUEUE_EVENT]){pageInfo{hasNextPage} nodes{__typename " + "... on ConnectedEvent{createdAt subject{__typename ... on Issue{number}}} ... on DisconnectedEvent{createdAt subject{__typename ... on Issue{number}}} " + - "... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}}}}}"; + "... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}} " + + "autoMerge:timelineItems(itemTypes:[AUTO_MERGE_ENABLED_EVENT,AUTO_MERGE_DISABLED_EVENT],last:1){nodes{__typename " + + "... on AutoMergeEnabledEvent{createdAt actor{__typename login}} ... on AutoMergeDisabledEvent{createdAt actor{__typename login}}}}}}}"; const CELLS = ["C2-auto-merge", "C3-ai-review-blocking", "C3-auto-merge"]; // A REST login (`app[bot]` for an App), compared exactly and case-insensitively. @@ -257,8 +259,9 @@ function eventKey(event) { // Where check() files a Failure, this emits a gate-failure, so demotion counts match: a red full // or canary run (at the run's update time); no dispatch run by a lane identity created within // DISPATCH_DEADLINE of the merge, once that deadline has passed (at the deadline); and, for a -// merge a lane identity enqueued, the pin read-back (read_pin): no unedited lane pin once the -// deadline has passed (at the deadline), or a pinned SHA that is not headRefOid (at the merge). +// merge a lane identity enqueued, other than its own native auto-merge, the pin read-back +// (read_pin): no unedited lane pin once the deadline has passed (at the deadline), or a pinned +// SHA that is not headRefOid (at the merge). const FULL_RUN_WORKFLOW = "pr-require-checks.yml"; const CANARY_WORKFLOW = "post-merge-detect-silent-revert.yml"; const DISPATCH_DEADLINE_MS = 6 * 3_600_000; @@ -300,10 +303,29 @@ function postMergeRuns(gh, repo, branch, merges, { lane, throughMs }) { const floor = Math.min(...merges.map((m) => ms(m.mergedAt, `#${m.number} mergedAt`))); const created = encodeURIComponent(`>=${new Date(floor).toISOString().replace(/\.\d{3}Z$/, "Z")}`); const byCreated = (a, b) => a.createdMs - b.createdMs; + // check()'s _Context.on_branch: a run's head_branch is only a name, which a run on a tag named + // like the branch carries too, so a run counts only when its commit is on refs/heads/. + // Residual: the tip is read here, before the run listings, where check() reads it after them, + // so a run on a commit that lands between the two reads is off the branch here alone. + const ref = gh(`repos/${repo}/git/ref/heads/${branch.split("/").map(encodeURIComponent).join("/")}`); + const tip = ref?.object?.sha; + if (!/^[0-9a-f]{40}$/.test(tip ?? "")) throw new PublishError(`refusing to publish: branch ${branch} has no tip commit`); + const onBranches = new Map(); + const onBranch = (sha) => { + if (!/^[0-9a-f]{40}$/.test(sha ?? "")) return false; + if (!onBranches.has(sha)) { + const status = sha === tip ? "identical" : gh(`repos/${repo}/compare/${sha}...${tip}?per_page=1&page=1`)?.status; + if (!["ahead", "identical", "behind", "diverged"].includes(status)) { + throw new PublishError(`refusing to publish: compare ${sha}...${tip} is ${JSON.stringify(status)}, so whether that run is on ${branch} is unknown`); + } + onBranches.set(sha, status === "ahead" || status === "identical"); + } + return onBranches.get(sha); + }; const list = (workflow, event) => pages(gh, `repos/${repo}/actions/workflows/${workflow}/runs?event=${event}&branch=${encodeURIComponent(branch)}&created=${created}`, "workflow_runs") .map((run) => ({ ...run, createdMs: ms(run.created_at, `run ${run.id}`), check: `${workflow} ${event}` })) - .filter((run) => run.head_branch === branch && run.createdMs >= floor); + .filter((run) => run.head_branch === branch && run.createdMs >= floor && onBranch(run.head_sha)); const fullRuns = [...list(FULL_RUN_WORKFLOW, "schedule"), ...list(FULL_RUN_WORKFLOW, "workflow_dispatch")].sort(byCreated); const canaryRuns = list(CANARY_WORKFLOW, "push").sort(byCreated); @@ -591,7 +613,20 @@ export function collect({ repo, since, through, laneLogins, gateChecks, aiReview const queued = timeline.filter((e) => e.__typename === "AddedToMergeQueueEvent").sort((a, b) => ms(a.createdAt, "a merge-queue event") - ms(b.createdAt, "a merge-queue event")); const actors = queued.length > 0 ? [queued.at(-1).actor, queued.at(-1).enqueuer] : [g.mergedBy]; const byIdentity = actors.some((actor) => actor && lane.has(actorKey(actor))); - return { autonomous: actors.some((actor) => !human(actor)) || lane.has(actorKey(g.mergedBy)), byIdentity }; + // babysit_postmerge.py native_auto_merge: a lane identity's own native auto-merge posts no + // pin. One lane identity opened it from this repository, enqueued it, and made its newest + // auto-merge enable or disable, an enable no later than the enqueue. When that identity + // opened it from this repository, unreadable auto-merge events refuse the publish, as the + // check reads them unreadable. + const who = actorKey(g.author); + const owned = byIdentity && lane.has(who) && actors.some((actor) => actor && actorKey(actor) === who) && g.isCrossRepository === false; + if (owned && !Array.isArray(g.autoMerge?.nodes)) throw new PublishError(`refusing to publish: #${g.number} has no auto-merge events`); + const armed = owned ? g.autoMerge.nodes.at(-1) : undefined; + const native = + armed?.__typename === "AutoMergeEnabledEvent" && + actorKey(armed.actor) === who && + (queued.length === 0 || ms(armed.createdAt, "an auto-merge event") <= ms(queued.at(-1).createdAt, "a merge-queue event")); + return { autonomous: actors.some((actor) => !human(actor)) || lane.has(actorKey(g.mergedBy)), byIdentity, native }; }; const details = new Map(); @@ -705,7 +740,7 @@ export function collect({ repo, since, through, laneLogins, gateChecks, aiReview // Then the post-merge workflow runs on later SHAs containing the merge (postMergeRuns). for (const r of tracked.filter((c) => c.autonomous)) { const wf = judgeWorkflows(r); - const pin = r.byIdentity ? readBack(r) : {}; + const pin = r.byIdentity && !r.native ? readBack(r) : {}; if (pin.failure) wf.failures.push(pin.failure); if (pin.pending) wf.unsettled = true; for (const failure of wf.failures) { diff --git a/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.test.mjs b/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.test.mjs index 85feeb2634..31ac4722bb 100755 --- a/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.test.mjs +++ b/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.test.mjs @@ -60,6 +60,53 @@ test("a red full run on a later SHA containing two merges yields gate-failure fo ); }); +test("a red run on a commit off the branch is not charged, as in _Context.on_branch", () => { + // A run on a tag named main reports head_branch main; its commit descends from the merge but + // is not on refs/heads/main. + const off = shaOf(0xa0ff); + const pulls = () => [pr(1601, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })]; + const red = (offBranch) => + run({ + pulls: pulls(), + history: [shaOf(1601), off, shaOf(0xa100)], + offBranch, + workflowRuns: { + ...verifiedEach(pulls()), + [FULL]: [...verifiedEach(pulls())[FULL], wfRun(7, "workflow_dispatch", off, "2026-09-01T00:30:00Z", { conclusion: "failure", actor: LANE })], + }, + }); + assert.deepEqual(red([off]).events, []); + // The same run with its commit on the branch is a gate failure. + assert.deepEqual(red([]).gateFailures.map((g) => [g.number, g.check]), [[1601, `${FULL} workflow_dispatch`]]); +}); + +test("a red run on a commit on top of the tip (compare behind) is not charged", () => { + // A tag named main one commit past refs/heads/main: compare ... answers behind. + const top = shaOf(0xa0fe); + const pulls = () => [pr(1602, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })]; + const result = run({ + pulls: pulls(), + onTop: [top], + workflowRuns: { + ...verifiedEach(pulls()), + [FULL]: [...verifiedEach(pulls())[FULL], wfRun(8, "workflow_dispatch", top, "2026-09-01T00:30:00Z", { conclusion: "failure", actor: LANE })], + }, + }); + assert.deepEqual([result.events, result.gateFailures], [[], []]); +}); + +test("an unreadable branch tip or run ancestry refuses the publish", () => { + const pulls = () => [pr(1603, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })]; + assert.throws(() => run({ pulls: pulls(), tip: null }), (error) => error instanceof PublishError && /has no tip commit/.test(error.message)); + const odd = shaOf(0xa0fd); + const unknown = { + pulls: pulls(), + unknownCompare: [odd], + workflowRuns: { ...verifiedEach(pulls()), [FULL]: [...verifiedEach(pulls())[FULL], wfRun(9, "schedule", odd, "2026-09-01T00:30:00Z")] }, + }; + assert.throws(() => run(unknown), (error) => error instanceof PublishError && /whether that run is on main is unknown/.test(error.message)); +}); + test("a run created before the merge is not its evidence", () => { // Run 3 on #1303's own SHA predates #1303's merge, so it is evidence for #1309 only. const result = run({ @@ -139,6 +186,39 @@ test("a lane-enqueued merge passes only when its unedited lane pin names headRef assert.deepEqual(gateOf(run({ pulls: bot, workflowRuns: verifiedEach(bot), comments: { 1503: [] } })), PASSED); }); +test("a lane identity's native auto-merge owes no pin, as in native_auto_merge", () => { + const armed = (kind, at, login = LANE) => ({ nodes: [{ __typename: kind, createdAt: at, actor: actorOf(login) }] }); + const enqueue = [queued("2026-08-31T23:50:00Z", LANE)]; + const pulls = (autoMerge, extra = {}, timeline = enqueue) => [pr(1505, "2026-09-01T00:00:00Z", { labels: [MECHANICAL], timeline, patch: { autoMerge, ...extra } })]; + const judge = (autoMerge, extra, lanes = [LANE], timeline = enqueue) => { + const result = run({ pulls: pulls(autoMerge, extra, timeline), workflowRuns: verifiedEach(pulls(autoMerge, extra, timeline)), comments: { 1505: [] } }, { laneLogins: lanes }); + return [gateOf(result), result.events]; + }; + const noPin = [FAILED, gateEvents("2026-09-01T06:00:00Z", "C2-auto-merge")]; + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z")), [PASSED, []]); + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:50:00Z")), [PASSED, []]); + // The gate's disarm, an arm after the enqueue, a person's arm, or no events: the pin stays owed. + assert.deepEqual(judge(armed("AutoMergeDisabledEvent", "2026-08-31T23:40:00Z")), noPin); + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:55:00Z")), noPin); + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z", "kyle-sexton")), noPin); + // Unreadable auto-merge events refuse the publish, as the check reads them unreadable. + assert.throws(() => judge(undefined), (error) => error instanceof PublishError && /no auto-merge events/.test(error.message)); + // One lane identity opened and armed it, another enqueued it: not one identity's native merge. + const lanes = [LANE, "other-lane[bot]"]; + const byOther = [queued("2026-08-31T23:50:00Z", "other-lane[bot]")]; + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), {}, lanes, byOther), noPin); + // One lane identity opened it and another armed it: not one identity's native merge. + const other = "other-lane[bot]"; + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z", other), {}, [LANE, other]), noPin); + // A lane arm on a pull request the lane did not open from this repository is off the gate. + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), { author: actorOf("kyle-sexton") }), noPin); + assert.deepEqual(judge(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z"), { isCrossRepository: true }), noPin); + // A red full run after a native merge is still its gate failure. + const native = pulls(armed("AutoMergeEnabledEvent", "2026-08-31T23:40:00Z")); + const red = run({ pulls: native, workflowRuns: { [FULL]: [wfRun(9, "schedule", shaOf(1505), "2026-09-01T02:00:00Z", { conclusion: "failure" })] }, comments: { 1505: [] } }); + assert.deepEqual(red.gateFailures.map((g) => [g.number, g.check]), [[1505, `${FULL} schedule`]]); +}); + test("a pin past the 30th comment page is no pin, as in read_pin", () => { const pulls = () => [pr(1504, "2026-09-01T00:00:00Z", { labels: [MECHANICAL] })]; const filler = (count) => Array.from({ length: count }, () => REC.commentsPage.data.repository.pullRequest.comments.nodes[0]); diff --git a/plugins/source-control/skills/babysit-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index 885e1717bd..5eeca08be8 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -851,9 +851,11 @@ review; the PR still merges only once the check reports SUCCESS. change to the plan's session-limit window. Auto-merge stays off because `ci-status` is the only required check and does not wait on the -review workflows, so an armed auto-merge could merge before AI review posts. Nothing enables it: -not the merge lane, not a Worker Contract subagent (`orchestration.md`), not a work-items worker -lane, not a standalone invocation, not `/source-control:pull-request`. +review workflows, so an armed auto-merge could merge before AI review posts. None of these enables +it: the merge lane, a Worker Contract subagent (`orchestration.md`), a work-items worker lane, a +standalone invocation, `/source-control:pull-request`. The one thing that does is pr-merge.yml's +`enable-auto-merge.mjs`, which arms the lanes App's own pull requests natively and re-arms one +whose auto-merge an App turned off; a person's disarm keeps it off. A push by a writer leaves an auto-merge armed (by a person, or before this rule), and the new head would merge on `ci-status` alone. @@ -861,11 +863,13 @@ Verified 2026-09-26 against GitHub's [Automatically merging a pull request](http which disables auto-merge only when "someone without write permissions pushes new changes to the head branch or switches the base branch". Recheck when that page names another event that disables auto-merge, or a GitHub changelog entry changes auto-merge behavior on push. -So every push path disarms it: `refresh_pr_branch.py` before updating the branch, `lane_push` -before and after each lane push in [loop.md](loop.md), and in +So every babysit push path disarms it: `refresh_pr_branch.py` before updating the branch, +`lane_push` before and after each lane push in [loop.md](loop.md), and in [orchestration.md](orchestration.md) the fix worker (Worker Contract and Worker Prompt Template) and the orchestrator's conflict-resolution push (Orchestrator Contract) before and after pushing. -Nothing re-arms it. +The pr-unblock lane's branch writers (`address-feedback.mjs` and `apply-patch.mjs`, which move the +head ref with the App token) do not disarm, and `enable-auto-merge.mjs` re-arms after a babysit +disarm, so a lanes App pull request's new head can merge natively (see the post-merge residuals). ### Security/P1 escalation has no exception; the pre-escalation resolver is bound by it too @@ -1100,7 +1104,17 @@ Named residuals: person with write permission can do either; a failure the post-merge check still finds refuses on its own and files a new record. - After a dispatch deadline with no unedited pin comment, an identity merge fails the read-back and - a demotion record is filed. + a demotion record is filed. A native auto-merge the identity armed on its own same-repository + pull request no later than its enqueue, and did not disarm since, posts no pin and needs no + read-back: it verifies on the dispatch and canary runs alone. +- A native merge's head is compared with no vetted head: a writer's push after the arm (the + pr-unblock writers above) leaves auto-merge armed, and that head merges natively with no + read-back. `ci-status` still runs the hold rules on the merge-group head. The pending + synchronize disarm in [#9450](https://github.com/melodic-software/claude-code-plugins/pull/9450) + closes this once it lands. +- Event times have one-second resolution, so an identity arm in the same second as a gate enqueue + counts as at or before it, and that gate merge reads as native and skips its read-back. So does + an arm between the gate's disarm check and its enqueue. - The pull request's author can apply the opt-in and `work-class:` labels: label provenance asks only that a human with write permission applied them in person, within write-access trust. Label provenance also cannot tell the operator from an agent driving the operator's own `gh` diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index b239ebf23f..e0e4ef76de 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py @@ -2674,8 +2674,9 @@ class AutoMerge: head_ref_oid: str | None merged_at: str | None url: str | None = None - # The agent identity landed it, so the wrapper pinned its head; any other - # actor has no pin and verifies on the dispatch and canary runs alone. + # The agent identity landed it: the gate's enqueue pinned its head, its + # native auto-merge (`babysit_postmerge.native_auto_merge`) did not. Any + # other actor has no pin and verifies on the dispatch and canary runs alone. by_identity: bool = False queued: bool = True diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 2bed2c4e0f..d018bb9115 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -15,8 +15,9 @@ dispatch run by the identity on a SHA containing the merge, a green canary run on such a SHA, and a matching read-back all exist, and no red full run or canary run counted against it first. Only an enqueue by the identity has a -pinned SHA to read back; any other enqueue verifies on the dispatch and canary -runs alone. A red full run is attributed after one `rerun-failed-jobs` the +pinned SHA to read back; any other enqueue, and the identity's own native +auto-merge (`native_auto_merge`), verifies on the dispatch and canary runs +alone. A red full run is attributed after one `rerun-failed-jobs` the wrapper triggers: red again is a failure, green is a flake. A red full run the pr-merge lane dispatched gets that one re-run from pr-merge.yml's own `post-merge-dispatch` job, which records it (`_Context.lane_rerun`); with @@ -480,9 +481,12 @@ def _graphql( "repository(owner:$o,name:$r){pullRequests(baseRefName:$b,states:MERGED," f"first:{MERGED_PAGE_SIZE},after:$c,orderBy:{{field:UPDATED_AT,direction:DESC}}){{" "pageInfo{hasNextPage endCursor} nodes{number url mergedAt updatedAt headRefOid " - "mergedBy{__typename login} " + "mergedBy{__typename login} author{__typename login} isCrossRepository " "mergeCommit{oid} timelineItems(itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT],last:1){nodes{" - "... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}}}}}}" + "... on AddedToMergeQueueEvent{createdAt actor{__typename login} enqueuer{__typename login}}}} " + "autoMerge:timelineItems(itemTypes:[AUTO_MERGE_ENABLED_EVENT,AUTO_MERGE_DISABLED_EVENT],last:1){nodes{" + "__typename ... on AutoMergeEnabledEvent{createdAt actor{__typename login}} " + "... on AutoMergeDisabledEvent{createdAt actor{__typename login}}}}}}}}" ) QUEUE_QUERY = ( "query PostMergeQueue($o:String!,$r:String!,$b:String!){repository(owner:$o,name:$r){" @@ -492,6 +496,48 @@ def _graphql( DEQUEUE_MUTATION = "mutation PostMergeDequeue($id:ID!){dequeuePullRequest(input:{id:$id}){clientMutationId}}" +def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bool: + """Whether the identity's merge landed through GitHub's native auto-merge + (`enable-auto-merge.mjs` arms it), which posts no pin: the identity opened + the pull request from a branch of this repository, as that script requires, + and the newest auto-merge enable or disable event is the identity's enable, + made no later than the merge-queue enqueue. An arm under the identity token + on anyone else's pull request keeps the pin read-back. The gate disarms an + armed auto-merge before it pins and enqueues, and an arm after its enqueue + is not what queued the head, so either keeps the pin read-back. + + Residuals: nothing compares a native merge's head with any vetted head. + `expectedHeadOid` binds only the head at arm time, and a writer's push + after the arm (address-feedback, apply-patch) leaves auto-merge armed, so + the later head merges natively. An arm that lands between the gate's + disarm check and its enqueue also reads as native and skips that merge's + read-back, and so does an arm in the same second as the enqueue: event + times have one-second resolution, so the comparison is at or before.""" + if not merge.by_identity: + return False + if ( + _actor_key(pr.get("author")) != identity + or pr.get("isCrossRepository") is not False + ): + return False + connection = pr.get("autoMerge") + if not is_json_object(connection) or not is_json_array(connection.get("nodes")): + raise PostMergeReadError(f"#{merge.number}: no auto-merge events") + if not connection["nodes"]: + return False + event = json_object(connection["nodes"][-1]) + if event.get("__typename") != "AutoMergeEnabledEvent": + return False + if _actor_key(event.get("actor")) != identity: + return False + if not merge.queued: + return True + queued = json_array(json_object(pr.get("timelineItems")).get("nodes")) + return _time(event.get("createdAt")) <= _time( + json_object(queued[-1]).get("createdAt") + ) + + def _default_branch(owner: str, name: str) -> str: """The repository's default branch; an unreadable one fails the read, so the check never judges a branch the repository does not merge into.""" @@ -916,7 +962,7 @@ def dispatch_runs_at(self, commit: str) -> dict[int, Run]: self.dispatch_at_cache[commit] = { r.id: r for r in _run_rows(rows, "workflow_dispatch", self.owner, self.name) - if r.head_branch == self.branch + if r.head_branch == self.branch and self.on_branch(r) } return self.dispatch_at_cache[commit] @@ -1069,12 +1115,32 @@ def affected(self, run: Run) -> frozenset[str] | None: and r.created_at < run.created_at and r.head_sha != run.head_sha ] - green = max(greens, key=lambda r: r.created_at, default=None) + green = self._newest_on_branch(greens) self.affected_cache[run.id] = ( None if green is None else affected_suites(green.head_sha, run.head_sha) ) return self.affected_cache[run.id] + def on_branch(self, run: Run) -> bool: + """Whether `run`'s head commit is on the branch as `refs/heads/` + names it. A run's `head_branch` is only a name: a run on a tag named + like the branch carries it too, and its commit (and plan script) can + be anyone's with write access.""" + return bool(_FULL_SHA.fullmatch(run.head_sha or "")) and self.contains( + run.head_sha, self._branch_tip() + ) + + def _newest_on_branch(self, runs: list[Run]) -> Run | None: + """The newest of `runs` on the branch (`on_branch`).""" + return next( + ( + r + for r in sorted(runs, key=lambda r: r.created_at, reverse=True) + if self.on_branch(r) + ), + None, + ) + def _quarantine_days(self, sha: str) -> int: if self.quarantine_days is None: self.quarantine_days = policy.read_config_at( @@ -1133,7 +1199,7 @@ def _baseline(self, signal: str, before: datetime) -> frozenset[str] | None: and r.head_branch == self.branch and r.status == "completed" ] - newest = max(runs, key=lambda r: r.created_at, default=None) + newest = self._newest_on_branch(runs) self.baseline_cache[key] = ( None if newest is None @@ -1457,6 +1523,7 @@ def _judge( pinned: str | None, now: datetime, tally: _Tally, + pin_required: bool, ) -> MergeState: pending: list[str] = [] failures: list[Failure] = [] @@ -1489,7 +1556,7 @@ def passes(run: Run) -> bool: r for r in canaries if ctx.outcome(r, attributable=False)[0] == GREEN ] - if merge.by_identity and pinned is None: + if pin_required and pinned is None: # A missing or edited pin is pending until the dispatch deadline, then # a failure, so a demotion record is filed and a human close restarts # the floor instead of the lane holding forever. @@ -1504,7 +1571,7 @@ def passes(run: Run) -> bool: ) else: pending.append("read-back-unrecorded") - elif merge.by_identity and pinned != merge.head_ref_oid: + elif pin_required and pinned != merge.head_ref_oid: failures.append( Failure( SIGNAL_READ_BACK, @@ -1670,6 +1737,9 @@ def _lane_reruns(ctx: _Context, now: datetime, tally: _Tally) -> list[str]: and run.attempt == 1 and ctx.outcome(run, attributable=True)[0] == RERUN and ctx.lane_dispatched(run) + # Only a run that could be marked pays for the ancestry read, + # and one off the branch never reaches the affected set. + and ctx.on_branch(run) and ctx.regressions(SIGNAL_DISPATCH, run, run.created_at) ): tally.reruns[run.id] = Rerun( @@ -1714,6 +1784,9 @@ def check( owner, name = _split_repo(repo) branch = branch or _default_branch(owner, name) merges: list[AutoMerge] = [] + # The identity's merges with a pin to read back: not its native + # auto-merges, which verify on the dispatch and canary runs alone. + pinned_merges: set[int] = set() warnings: list[str] = [] identity = _login_key(agent_identity) for pr in _merged_since(owner, name, branch, merged_since): @@ -1724,6 +1797,8 @@ def check( warnings.append(derived) elif derived is not None: merges.append(derived) + if derived.by_identity and not native_auto_merge(pr, derived, identity): + pinned_merges.add(derived.number) merges.sort(key=lambda m: (str(m.merged_at), m.number)) ctx = _Context( owner, @@ -1762,9 +1837,18 @@ def check( floor, ) ] - full_runs.sort(key=lambda item: item[1].created_at) + # A listing filtered by branch name also returns runs on a tag named + # like the branch, whose red would be charged to the merge. + full_runs = sorted( + (item for item in full_runs if ctx.on_branch(item[1])), + key=lambda item: item[1].created_at, + ) canary_runs = sorted( - _runs(owner, name, CANARY_WORKFLOW, "push", branch, floor), + ( + r + for r in _runs(owner, name, CANARY_WORKFLOW, "push", branch, floor) + if ctx.on_branch(r) + ), key=lambda r: r.created_at, ) states = tuple( @@ -1773,7 +1857,10 @@ def check( ctx, full_runs=full_runs, canary_runs=canary_runs, - pinned=pinned_sha(merge) if pinned_sha and merge.by_identity else None, + pinned=pinned_sha(merge) + if pinned_sha and merge.number in pinned_merges + else None, + pin_required=merge.number in pinned_merges, now=now, tally=tally, ) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/fixtures/postmerge/merged-pr.json b/plugins/source-control/skills/babysit-prs/scripts/tests/fixtures/postmerge/merged-pr.json index 2bf33f4f17..e07e8d4968 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/fixtures/postmerge/merged-pr.json +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/fixtures/postmerge/merged-pr.json @@ -13,5 +13,8 @@ "enqueuer": {"__typename": "User", "login": "example-sync[bot]"} } ] - } + }, + "autoMerge": {"nodes": []}, + "author": {"__typename": "Bot", "login": "agent-lanes"}, + "isCrossRepository": false } diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py index ffb4deebbf..def7ac76c7 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge.py @@ -176,7 +176,8 @@ def __init__(self) -> None: # SHAs main reached at or after MERGE_SHA; anything else does not contain it. self.descendants = {MERGE_SHA, LATER_SHA} # Commits on main itself: the history of its tip, `refs/heads/main`. - self.on_main = {MERGE_SHA, LATER_SHA, TIP_SHA} + # HEAD_SHA stands for a commit before the merge. + self.on_main = {HEAD_SHA, MERGE_SHA, LATER_SHA, TIP_SHA} self.main_tip = TIP_SHA # What a compare against the bare name `main` reaches; None means the # branch, a set stands in for a same-named tag on another history. @@ -711,6 +712,211 @@ def test_red_run_after_verification_does_not_count_against_the_merge(self) -> No self.assertEqual(verdict.reruns, ()) +def auto_merge_event( + actor: Any, + kind: str = "AutoMergeEnabledEvent", + at: str = "2026-10-12T09:40:00Z", +) -> dict[str, Any]: + """The `autoMerge` connection: the newest auto-merge enable or disable.""" + return {"nodes": [{"__typename": kind, "createdAt": at, "actor": actor}]} + + +class NativeAutoMerge(CheckCase): + """`enable-auto-merge.mjs` arms the lanes App's pull request with the App + token, so GitHub enqueues it under the identity with no pin comment: the + merge is the identity's and verifies on the dispatch and canary runs.""" + + LATE = datetime(2026, 10, 12, 17, 0, tzinfo=UTC) + + def setUp(self) -> None: + super().setUp() + self.native(auto_merge_event(APP_ACTOR)) + + def native(self, events: dict[str, Any]) -> None: + self.gh.merged = [identity_merge(autoMerge=events)] + + def check_unpinned(self, now: datetime | None = None) -> pm.PostMergeVerdict: + def no_pin_read(merge: Any) -> str | None: + raise AssertionError(f"read the pin of #{merge.number}") + + return pm.check( + REPO, + agent_identity=IDENTITY, + is_human=human, + merged_since=SINCE, + now=now or self.LATE, + pinned_sha=no_pin_read, + ) + + def test_a_native_merge_verifies_without_a_pin_past_the_deadline(self) -> None: + self.all_green() + verdict = self.check_unpinned() + self.assertTrue(verdict.merges[0].merge.by_identity) + self.assertEqual(verdict.failures, ()) + self.assertTrue(verdict.merges[0].verified) + self.assertTrue(verdict.ok) + + def test_an_arm_at_the_enqueue_time_is_native(self) -> None: + self.native(auto_merge_event(APP_ACTOR, at="2026-10-12T09:50:00Z")) + self.all_green() + self.assertTrue(self.check_unpinned().merges[0].verified) + + def test_another_enqueue_without_auto_merge_events_stays_readable(self) -> None: + self.gh.merged = [merged_pr(autoMerge=None)] + self.all_green() + verdict = self.check_unpinned() + self.assertTrue(verdict.readable) + self.assertTrue(verdict.merges[0].verified) + + def test_a_native_merge_with_no_dispatch_run_asks_for_one(self) -> None: + self.gh.add_runs("post-merge-detect-silent-revert.yml", "push", canary_run(21)) + verdict = self.check_unpinned(datetime(2026, 10, 12, 11, 0, tzinfo=UTC)) + self.assertEqual(verdict.merges[0].pending, ("dispatch-run-pending",)) + self.assertTrue(verdict.needs_dispatch) + + def test_a_native_merge_is_judged_by_the_lane(self) -> None: + self.all_green() + verdict = pm.check( + REPO, + agent_identity=IDENTITY, + is_human=human, + merged_since=SINCE, + now=self.LATE, + lane_merges_only=True, + ) + self.assertEqual(self.numbers(verdict), [7001]) + self.assertTrue(verdict.merges[0].verified) + + def test_a_red_main_after_a_native_merge_demotes(self) -> None: + self.gh.add_runs("post-merge-detect-silent-revert.yml", "push", canary_run(21)) + self.gh.add_runs( + "pr-require-checks.yml", + "workflow_dispatch", + dispatch_run(11, "failure", run_attempt=2, triggering_actor=by(IDENTITY)), + ) + self.gh.attempts[(11, 1)] = dispatch_run(11, conclusion="failure") + verdict = self.check_unpinned() + self.assertEqual( + [(f.signal, f.merge.number) for f in verdict.failures], [("dispatch", 7001)] + ) + self.assertFalse(verdict.ok) + + def test_a_red_canary_after_a_native_merge_demotes(self) -> None: + self.gh.add_runs("pr-require-checks.yml", "workflow_dispatch", dispatch_run(11)) + self.gh.add_runs( + "post-merge-detect-silent-revert.yml", + "push", + canary_run(21, conclusion="failure"), + ) + self.assertEqual([f.signal for f in self.check_unpinned().failures], ["canary"]) + + def test_a_gate_merge_still_fails_its_read_back_past_the_deadline(self) -> None: + self.native({"nodes": []}) + self.all_green() + verdict = self.check(pinned=None, now=self.LATE) + self.assertEqual([f.signal for f in verdict.failures], ["read-back"]) + + def test_the_pin_stays_required_unless_the_identity_armed_before_the_enqueue( + self, + ) -> None: + slug_user = {"__typename": "User", "login": "agent-lanes"} + cases = { + "disarmed since": auto_merge_event(APP_ACTOR, "AutoMergeDisabledEvent"), + "armed after the enqueue": auto_merge_event( + APP_ACTOR, at="2026-10-12T09:55:00Z" + ), + "armed by a person": auto_merge_event(HUMAN), + "armed by a user named like the app": auto_merge_event(slug_user), + } + self.all_green() + for name, events in cases.items(): + with self.subTest(name): + self.native(events) + verdict = self.check(pinned=None, now=self.LATE) + self.assertEqual([f.signal for f in verdict.failures], ["read-back"]) + + def test_an_identity_arm_on_a_pull_request_it_did_not_open_needs_its_pin( + self, + ) -> None: + # enable-auto-merge.mjs arms only the identity's own same-repository + # pull requests; any other arm under its token is off the gate. + self.all_green() + for name, fields in { + "opened by a person": {"author": HUMAN}, + "from a fork": {"isCrossRepository": True}, + "unknown origin": {"isCrossRepository": None}, + }.items(): + with self.subTest(name): + self.gh.merged = [ + identity_merge(autoMerge=auto_merge_event(APP_ACTOR), **fields) + ] + verdict = self.check(pinned=None, now=self.LATE) + self.assertEqual([f.signal for f in verdict.failures], ["read-back"]) + + def test_a_queue_bypassing_native_merge_needs_no_pin(self) -> None: + self.gh.merged = [ + merged_pr( + timelineItems={"nodes": []}, + mergedBy=APP_ACTOR, + autoMerge=auto_merge_event(APP_ACTOR), + ) + ] + self.all_green() + self.assertTrue(self.check_unpinned().ok) + + def test_an_identity_merge_without_its_auto_merge_events_is_unreadable( + self, + ) -> None: + for events in (None, {"nodes": None}): + with self.subTest(events=events): + self.native(events) + verdict = self.check(now=self.LATE) + self.assertFalse(verdict.readable) + self.assertIn("no auto-merge events", verdict.reason) + + +class RunsOffTheBranch(CheckCase): + """A run on a tag named like the branch reports the branch as its + `head_branch`. Its commit may descend from the merge, yet it is not on + `refs/heads/main`, so it is neither the merge's evidence nor its failure.""" + + def setUp(self) -> None: + super().setUp() + self.gh.descendants.add(FORK_SHA) + + def test_a_red_schedule_run_off_the_branch_is_not_charged(self) -> None: + self.all_green() + self.gh.add_runs( + "pr-require-checks.yml", + "schedule", + schedule_run(31, head_sha=FORK_SHA, created_at="2026-10-12T10:02:00Z"), + ) + verdict = self.check() + self.assertEqual(verdict.failures, ()) + self.assertEqual(verdict.reruns, ()) + self.assertTrue(verdict.merges[0].verified) + + def test_a_red_canary_off_the_branch_is_not_charged(self) -> None: + self.all_green() + self.gh.add_runs( + "post-merge-detect-silent-revert.yml", + "push", + canary_run(22, conclusion="failure", head_sha=FORK_SHA), + ) + self.assertEqual(self.check().failures, ()) + + def test_a_green_dispatch_off_the_branch_does_not_verify(self) -> None: + self.gh.add_runs( + "pr-require-checks.yml", + "workflow_dispatch", + dispatch_run(11, head_sha=FORK_SHA), + ) + self.gh.add_runs("post-merge-detect-silent-revert.yml", "push", canary_run(21)) + verdict = self.check() + self.assertFalse(verdict.merges[0].verified) + self.assertTrue(verdict.needs_dispatch) + + class DroppedDispatchListing(CheckCase): """A listing that answers without runs it holds (a degraded API) must not read as "no dispatch run": past the deadline, a read by the merge @@ -817,6 +1023,21 @@ def test_a_dropped_run_at_a_descendant_makes_the_verdict_unreadable( def reads_by_sha(self, sha: str) -> bool: return any(f"head_sha={sha}" in " ".join(c) for c in self.gh.calls) + def test_a_dropped_run_off_the_branch_leaves_the_failure_standing( + self, + ) -> None: + # The commit listing names a commit off refs/heads/main (a tag named + # main): a run at it is no run the listing should have held. + self.gh.descendants.add(FORK_SHA) + self.gh.branch_commits = [commit_on_main(FORK_SHA, "2026-10-12T15:00:00Z")] + self.gh.unlisted[("pr-require-checks.yml", "workflow_dispatch")] = [ + dispatch_run(11, head_sha=FORK_SHA) + ] + verdict = self.check(now=self.LATE) + self.assertTrue(self.reads_by_sha(FORK_SHA)) + self.assertTrue(verdict.readable, verdict.reason) + self.assertEqual([f.signal for f in verdict.failures], ["dispatch"]) + def test_a_descendant_in_the_window_is_read_by_sha(self) -> None: self.gh.branch_commits = [commit_on_main(LATER_SHA, "2026-10-12T15:00:00Z")] verdict = self.check(now=self.LATE) @@ -1711,6 +1932,19 @@ def before_merge(self, conclusion: str, *jobs: dict[str, Any]) -> None: if jobs: self.gh.jobs[(30, 1)] = list(jobs) + def test_a_baseline_run_on_a_commit_not_on_main_is_ignored(self) -> None: + # A red run on a tag named main cannot excuse a check: its commit is + # not main's, so the merge is judged as if it had no baseline. + self.gh.add_runs( + "pr-require-checks.yml", + "schedule", + schedule_run(30, "failure", head_sha=FORK_SHA, created_at=self.BEFORE), + ) + self.gh.jobs[(30, 1)] = [failed_job(301, "test-bash", "Run the bash tests")] + self.red_after_merge(failed_job(312, "test-bash", "Run the bash tests")) + verdict = self.check() + self.assertEqual([f.signal for f in verdict.failures], ["schedule"]) + def test_a_check_already_red_before_the_merge_is_not_charged_to_it(self) -> None: self.before_merge("failure", failed_job(301, "test-bash", "Run the bash tests")) self.red_after_merge(failed_job(312, "test-bash", "Run the bash tests")) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge_quarantine.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge_quarantine.py index 81b70b65f8..04efd65b96 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge_quarantine.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_postmerge_quarantine.py @@ -46,6 +46,7 @@ import babysit_postmerge as pm # noqa: E402 from test_babysit_postmerge import ( # noqa: E402 FIXTURES, + FORK_SHA, HEAD_SHA, IDENTITY, LATER_SHA, @@ -106,6 +107,8 @@ def __call__( class AffectedBase(CheckCase): def setUp(self) -> None: super().setUp() + # The last green full run's commit (below) is on main. + self.gh.on_main.add(OTHER_SHA) self.fake = QuarantineGh(self.gh) for module in (pm, pm.policy): patcher = mock.patch.object(module, "gh_capture", self.fake) @@ -170,6 +173,18 @@ def posts(self) -> list[list[str]]: class AffectedSet(AffectedBase): + def test_a_newer_green_run_on_a_commit_not_on_main_is_ignored(self) -> None: + # A run on a tag named main reports `head_branch: main`; its commit + # (and the plan script it would run) is not main's. + self.gh.add_runs( + "pr-require-checks.yml", + "workflow_dispatch", + dispatch_run(6, head_sha=FORK_SHA, created_at="2026-10-12T09:00:00Z"), + ) + self.red_dispatch(bash_log((FLAKY, False), (TOUCHED, True))) + self.check() + self.affected_calls.assert_called_once_with(OTHER_SHA, LATER_SHA) + def test_an_unrelated_red_suite_files_a_quarantine_issue_and_does_not_demote( self, ) -> None: @@ -519,6 +534,11 @@ def test_an_in_set_red_is_marked_for_the_lanes_one_rerun(self) -> None: self.assertEqual(self.lane_reruns(verdict), [(11, True)]) self.assertEqual(pm.verdict_exit(verdict), 0) + def test_a_red_run_off_the_branch_gets_no_rerun(self) -> None: + # Run 11 on a tag named main: its commit is not on refs/heads/main. + self.lane_red(bash_log((TOUCHED, False)), head_sha=FORK_SHA) + self.assertEqual(self.lane_reruns(self.lane_check()), []) + def test_an_out_of_set_red_gets_no_rerun_and_files_no_quarantine(self) -> None: # The alert's --judge-run already excuses and quarantines it. A lane # merge is judged, so the verdict carries quarantines, but run 11's