From da6655ae3da6cac898e750b55ec049d7ddb65a3e Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:24:37 -0400 Subject: [PATCH 1/9] fix(source-control): judge a native auto-merge of a lane pull request in post-merge A lanes App pull request that GitHub's native auto-merge lands posts no babysit-merge-pin comment, yet its enqueue is the identity's, so the post-merge check waited for a pin read-back and failed it at the dispatch deadline, filing a demotion record. The merged-pull-request query now also reads the newest auto-merge enable or disable event through a second aliased timelineItems connection. When that event is the identity's enable, made no later than the enqueue, the merge skips the pin read and verifies on the dispatch and canary runs alone. It stays an identity merge, so post-merge-dispatch still starts its full run, and a red full run or canary still fails it. Co-Authored-By: Claude Opus 5.5 --- ...stmerge-native-auto-merge-9447-a3c5e1d7.md | 7 + .../skills/babysit-prs/reference/safety.md | 4 +- .../babysit-prs/scripts/babysit_postmerge.py | 53 ++++++- .../tests/fixtures/postmerge/merged-pr.json | 3 +- .../scripts/tests/test_babysit_postmerge.py | 133 ++++++++++++++++++ 5 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 .changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md 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..c899adbb91 --- /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 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. 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-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index 885e1717bd..1c0ffb5493 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -1100,7 +1100,9 @@ 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 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. - 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_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 2bed2c4e0f..b7ec6e09ea 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 @@ -482,7 +483,10 @@ def _graphql( "pageInfo{hasNextPage endCursor} nodes{number url mergedAt updatedAt headRefOid " "mergedBy{__typename login} " "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,33 @@ 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 newest + auto-merge enable or disable event is the identity's enable, made no later + than the merge-queue enqueue. 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.""" + if not merge.by_identity: + 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.""" @@ -1457,6 +1488,7 @@ def _judge( pinned: str | None, now: datetime, tally: _Tally, + native: bool, ) -> MergeState: pending: list[str] = [] failures: list[Failure] = [] @@ -1489,7 +1521,10 @@ 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: + # A native auto-merge has no pin; it verifies on the dispatch and canary + # runs alone, like any other enqueue. + pinned_merge = merge.by_identity and not native + if pinned_merge 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 +1539,7 @@ def passes(run: Run) -> bool: ) else: pending.append("read-back-unrecorded") - elif merge.by_identity and pinned != merge.head_ref_oid: + elif pinned_merge and pinned != merge.head_ref_oid: failures.append( Failure( SIGNAL_READ_BACK, @@ -1714,6 +1749,7 @@ def check( owner, name = _split_repo(repo) branch = branch or _default_branch(owner, name) merges: list[AutoMerge] = [] + native: set[int] = set() warnings: list[str] = [] identity = _login_key(agent_identity) for pr in _merged_since(owner, name, branch, merged_since): @@ -1724,6 +1760,8 @@ def check( warnings.append(derived) elif derived is not None: merges.append(derived) + if native_auto_merge(pr, derived, identity): + native.add(derived.number) merges.sort(key=lambda m: (str(m.merged_at), m.number)) ctx = _Context( owner, @@ -1773,7 +1811,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.by_identity and merge.number not in native + else None, + native=merge.number in native, 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..aa2ceda0ef 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,6 @@ "enqueuer": {"__typename": "User", "login": "example-sync[bot]"} } ] - } + }, + "autoMerge": {"nodes": []} } 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..9205a9ecf0 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 @@ -711,6 +711,139 @@ 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_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_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 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 From 70a3a20787b23b0ee34b33987a169a32b54f8a67 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:36:02 -0400 Subject: [PATCH 2/9] fix(source-control): mirror the native auto-merge rule in promotion evidence publish-promotion-evidence.mjs mirrors the post-merge check's failures so demotion counts match, and still owed a pin for a lane's native auto-merge. It now reads the same aliased auto-merge events and skips the pin read-back by the same rule. Both copies also require that the identity opened the pull request from a branch of this repository, as enable-auto-merge.mjs does before it arms, so an arm under the identity token on any other pull request keeps its pin read-back. The pin requirement is decided once in check(), an arm at the enqueue time and an unrelated merge with no auto-merge events are covered, and the AutoMerge comment names the native case. Co-Authored-By: Claude Opus 5.5 --- .../github-fake.mjs | 3 ++ .../scripts/publish-promotion-evidence.mjs | 27 ++++++++++--- .../publish-promotion-evidence.test.mjs | 25 ++++++++++++ .../scripts/babysit_merge_policy.py | 5 ++- .../babysit-prs/scripts/babysit_postmerge.py | 40 +++++++++++-------- .../tests/fixtures/postmerge/merged-pr.json | 4 +- .../scripts/tests/test_babysit_postmerge.py | 30 ++++++++++++++ 7 files changed, 109 insertions(+), 25 deletions(-) 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..929143fef0 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: { 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..629722721e 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; @@ -591,7 +594,19 @@ 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. A lane identity opened it from this repository, and its newest auto-merge enable or + // disable is a lane identity's enable, made no later than the enqueue. Missing data reads as + // not native, so the pin stays owed. + const armed = Array.isArray(g.autoMerge?.nodes) ? g.autoMerge.nodes.at(-1) : undefined; + const native = + byIdentity && + lane.has(actorKey(g.author)) && + g.isCrossRepository === false && + armed?.__typename === "AutoMergeEnabledEvent" && + lane.has(actorKey(armed.actor)) && + (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 +720,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..c7e273ca80 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 @@ -139,6 +139,31 @@ 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 = {}) => [pr(1505, "2026-09-01T00:00:00Z", { labels: [MECHANICAL], timeline: enqueue, patch: { autoMerge, ...extra } })]; + const judge = (autoMerge, extra) => { + const result = run({ pulls: pulls(autoMerge, extra), workflowRuns: verifiedEach(pulls(autoMerge, extra)), comments: { 1505: [] } }); + 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); + assert.deepEqual(judge(undefined), 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/scripts/babysit_merge_policy.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_merge_policy.py index 4ea02f4fcf..16310fffe7 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 @@ -2693,8 +2693,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 b7ec6e09ea..261373c5ce 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -481,7 +481,7 @@ 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}}}} " "autoMerge:timelineItems(itemTypes:[AUTO_MERGE_ENABLED_EVENT,AUTO_MERGE_DISABLED_EVENT],last:1){nodes{" @@ -498,13 +498,22 @@ def _graphql( 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 newest - auto-merge enable or disable event is the identity's enable, made no later - than the merge-queue enqueue. The gate disarms an armed auto-merge before + (`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.""" + head, so either keeps the pin read-back. An arm that lands between the + gate's disarm check and its enqueue reads as native, so that merge skips + its read-back; the arm's `expectedHeadOid` still bound the head.""" 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") @@ -1488,7 +1497,7 @@ def _judge( pinned: str | None, now: datetime, tally: _Tally, - native: bool, + pin_required: bool, ) -> MergeState: pending: list[str] = [] failures: list[Failure] = [] @@ -1521,10 +1530,7 @@ def passes(run: Run) -> bool: r for r in canaries if ctx.outcome(r, attributable=False)[0] == GREEN ] - # A native auto-merge has no pin; it verifies on the dispatch and canary - # runs alone, like any other enqueue. - pinned_merge = merge.by_identity and not native - if pinned_merge 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. @@ -1539,7 +1545,7 @@ def passes(run: Run) -> bool: ) else: pending.append("read-back-unrecorded") - elif pinned_merge and pinned != merge.head_ref_oid: + elif pin_required and pinned != merge.head_ref_oid: failures.append( Failure( SIGNAL_READ_BACK, @@ -1749,7 +1755,9 @@ def check( owner, name = _split_repo(repo) branch = branch or _default_branch(owner, name) merges: list[AutoMerge] = [] - native: set[int] = set() + # 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): @@ -1760,8 +1768,8 @@ def check( warnings.append(derived) elif derived is not None: merges.append(derived) - if native_auto_merge(pr, derived, identity): - native.add(derived.number) + 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, @@ -1812,9 +1820,9 @@ def check( full_runs=full_runs, canary_runs=canary_runs, pinned=pinned_sha(merge) - if pinned_sha and merge.by_identity and merge.number not in native + if pinned_sha and merge.number in pinned_merges else None, - native=merge.number in native, + 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 aa2ceda0ef..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 @@ -14,5 +14,7 @@ } ] }, - "autoMerge": {"nodes": []} + "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 9205a9ecf0..1fe6f0e771 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 @@ -755,6 +755,18 @@ def test_a_native_merge_verifies_without_a_pin_past_the_deadline(self) -> None: 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)) @@ -822,6 +834,24 @@ def test_the_pin_stays_required_unless_the_identity_armed_before_the_enqueue( 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( From d106d32ff67e3b96a2ca57b70edff934206e101c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:37:18 -0400 Subject: [PATCH 3/9] docs(source-control): name the native auto-merge author rule and promotion evidence Co-Authored-By: Claude Opus 5.5 --- .../fix-postmerge-native-auto-merge-9447-a3c5e1d7.md | 2 +- plugins/source-control/skills/babysit-prs/reference/safety.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index c899adbb91..c489f37ba1 100644 --- a/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md +++ b/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md @@ -4,4 +4,4 @@ bump: patch ### Fixed -- The post-merge check (`babysit_postmerge.py`) recognizes a native auto-merge of the agent identity's pull request: 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. 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. +- 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, 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-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index 1c0ffb5493..201bb5d581 100644 --- a/plugins/source-control/skills/babysit-prs/reference/safety.md +++ b/plugins/source-control/skills/babysit-prs/reference/safety.md @@ -1100,8 +1100,8 @@ 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 native auto-merge the identity armed no later than its enqueue, - and did not disarm since, posts no pin and needs no read-back: it verifies on the dispatch and + 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. - 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. From 4ce9a7918250616d3e8b9981ad342c972ddb2030 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:46:07 -0400 Subject: [PATCH 4/9] docs(source-control): state the native auto-merge head residual plainly The native_auto_merge docstring said the arm's expectedHeadOid still bound the head. It binds only the head at arm time; a writer's push after the arm leaves auto-merge armed, so name that residual instead. Co-Authored-By: Claude Opus 5.5 --- .../babysit-prs/scripts/babysit_postmerge.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 261373c5ce..1289304d09 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -502,11 +502,16 @@ def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bo 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. An arm that lands between the - gate's disarm check and its enqueue reads as native, so that merge skips - its read-back; the arm's `expectedHeadOid` still bound the head.""" + 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.""" if not merge.by_identity: return False if ( From f7379a781c38fff8fcfbc71bc86ae3b0464832ab Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:54:55 -0400 Subject: [PATCH 5/9] fix(source-control): trust only a full run whose commit is on the branch _Context.affected and _Context._baseline picked the newest run whose head_branch named the branch. A run on a tag named main reports that too, so a writer's own commit could supply the plan script that sets the affected set, or the baseline that excuses a red check. Both now take the newest run whose head commit is the tip of refs/heads/ or its ancestor, through _Context.contains. Co-Authored-By: Claude Opus 5.5 --- ...stmerge-green-run-on-main-9466-6b2f8c04.md | 7 +++++++ .../babysit-prs/scripts/babysit_postmerge.py | 19 +++++++++++++++++-- .../scripts/tests/test_babysit_postmerge.py | 16 +++++++++++++++- .../test_babysit_postmerge_quarantine.py | 15 +++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 .changes/source-control/fix-postmerge-green-run-on-main-9466-6b2f8c04.md 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..444efc0a32 --- /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.contains`). 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/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 1289304d09..67abfb21d5 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -1114,12 +1114,27 @@ 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 _newest_on_branch(self, runs: list[Run]) -> Run | None: + """The newest of `runs` whose 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 next( + ( + r + for r in sorted(runs, key=lambda r: r.created_at, reverse=True) + if _FULL_SHA.fullmatch(r.head_sha or "") + and self.contains(r.head_sha, self._branch_tip()) + ), + None, + ) + def _quarantine_days(self, sha: str) -> int: if self.quarantine_days is None: self.quarantine_days = policy.read_config_at( @@ -1178,7 +1193,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 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 1fe6f0e771..c92fc67fa9 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. @@ -1874,6 +1875,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..0d0efbc9a6 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: From a93c9d269b426ee018169db182d4bd84d611d8bb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:12:56 -0400 Subject: [PATCH 6/9] fix(source-control): align native-merge rules and judge only runs on the branch - publish-promotion-evidence.mjs refuses to publish when the lane identity's auto-merge events cannot be read, as the post-merge check reads them unreadable, and requires one lane identity as author, enqueuer and armer. - The scheduled, dispatched and canary runs a merge is judged on, the runs read by commit, and the lane re-run probe keep only runs whose head commit is on refs/heads/, so a red run on a tag named like the branch cannot fail a merge. - safety.md names enable-auto-merge.mjs as what arms and re-arms lanes App pull requests, names the pr-unblock branch writers that do not disarm, and adds the head and same-second residuals. Co-Authored-By: Claude Opus 5.5 --- ...stmerge-green-run-on-main-9466-6b2f8c04.md | 2 +- ...stmerge-native-auto-merge-9447-a3c5e1d7.md | 2 +- .../scripts/publish-promotion-evidence.mjs | 17 ++++---- .../publish-promotion-evidence.test.mjs | 10 +++-- .../skills/babysit-prs/reference/safety.md | 28 +++++++++---- .../babysit-prs/scripts/babysit_postmerge.py | 38 ++++++++++++----- .../scripts/tests/test_babysit_postmerge.py | 42 +++++++++++++++++++ 7 files changed, 107 insertions(+), 32 deletions(-) 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 index 444efc0a32..98a299fa53 100644 --- 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 @@ -4,4 +4,4 @@ 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.contains`). 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. +- 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. 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 index c489f37ba1..a5b500a9cd 100644 --- a/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md +++ b/.changes/source-control/fix-postmerge-native-auto-merge-9447-a3c5e1d7.md @@ -4,4 +4,4 @@ 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, 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. +- 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/publish-promotion-evidence.mjs b/plugins/source-control/skills/babysit-loop/scripts/publish-promotion-evidence.mjs index 629722721e..c0cdb36e41 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 @@ -595,16 +595,17 @@ export function collect({ repo, since, through, laneLogins, gateChecks, aiReview 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))); // babysit_postmerge.py native_auto_merge: a lane identity's own native auto-merge posts no - // pin. A lane identity opened it from this repository, and its newest auto-merge enable or - // disable is a lane identity's enable, made no later than the enqueue. Missing data reads as - // not native, so the pin stays owed. - const armed = Array.isArray(g.autoMerge?.nodes) ? g.autoMerge.nodes.at(-1) : undefined; + // 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 = - byIdentity && - lane.has(actorKey(g.author)) && - g.isCrossRepository === false && armed?.__typename === "AutoMergeEnabledEvent" && - lane.has(actorKey(armed.actor)) && + 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 }; }; 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 c7e273ca80..2ad1eaa6eb 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 @@ -143,8 +143,8 @@ 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 = {}) => [pr(1505, "2026-09-01T00:00:00Z", { labels: [MECHANICAL], timeline: enqueue, patch: { autoMerge, ...extra } })]; - const judge = (autoMerge, extra) => { - const result = run({ pulls: pulls(autoMerge, extra), workflowRuns: verifiedEach(pulls(autoMerge, extra)), comments: { 1505: [] } }); + const judge = (autoMerge, extra, lanes = [LANE]) => { + const result = run({ pulls: pulls(autoMerge, extra), workflowRuns: verifiedEach(pulls(autoMerge, extra)), comments: { 1505: [] } }, { laneLogins: lanes }); return [gateOf(result), result.events]; }; const noPin = [FAILED, gateEvents("2026-09-01T06:00:00Z", "C2-auto-merge")]; @@ -154,7 +154,11 @@ test("a lane identity's native auto-merge owes no pin, as in native_auto_merge", 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); - assert.deepEqual(judge(undefined), 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 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); diff --git a/plugins/source-control/skills/babysit-prs/reference/safety.md b/plugins/source-control/skills/babysit-prs/reference/safety.md index 201bb5d581..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 @@ -1101,8 +1105,16 @@ Named residuals: 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 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. + 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_postmerge.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py index 67abfb21d5..18b4b61da4 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -511,7 +511,8 @@ def native_auto_merge(pr: dict[str, Any], merge: AutoMerge, identity: str) -> bo 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.""" + 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 ( @@ -961,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] @@ -1120,17 +1121,22 @@ def affected(self, run: Run) -> frozenset[str] | None: ) 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` whose 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.""" + """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 _FULL_SHA.fullmatch(r.head_sha or "") - and self.contains(r.head_sha, self._branch_tip()) + if self.on_branch(r) ), None, ) @@ -1727,7 +1733,8 @@ def _lane_reruns(ctx: _Context, now: datetime, tally: _Tally) -> list[str]: ) for run in sorted(runs, key=lambda r: r.created_at, reverse=True): if ( - run.id not in tally.reruns + ctx.on_branch(run) + and run.id not in tally.reruns and run.attempt == 1 and ctx.outcome(run, attributable=True)[0] == RERUN and ctx.lane_dispatched(run) @@ -1828,9 +1835,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( 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 c92fc67fa9..f507b7ca61 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 @@ -875,6 +875,48 @@ def test_an_identity_merge_without_its_auto_merge_events_is_unreadable( 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 From fed8a3480694488447c7f9d7163ad1f45393dbec Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:23:36 -0400 Subject: [PATCH 7/9] fix(source-control): judge promotion-evidence runs only on the branch publish-promotion-evidence.mjs kept post-merge runs by head_branch name alone, so a red dispatch on a tag named main was a gate failure there while the post-merge check ignores it. It now keeps a run only when its commit is the tip of refs/heads/ or its ancestor, as _Context.on_branch does. Tests now cover each filter that no test killed when removed: the Node on-branch filter, the Node author-is-enqueuer conjunct, the dispatch_runs_at filter, and the lane re-run probe filter. Co-Authored-By: Claude Opus 5.5 --- ...stmerge-green-run-on-main-9466-6b2f8c04.md | 2 +- .../github-fake.mjs | 8 ++++- .../scripts/publish-promotion-evidence.mjs | 19 +++++++++++- .../publish-promotion-evidence.test.mjs | 30 +++++++++++++++++-- .../scripts/tests/test_babysit_postmerge.py | 15 ++++++++++ .../test_babysit_postmerge_quarantine.py | 5 ++++ 6 files changed, 73 insertions(+), 6 deletions(-) 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 index 98a299fa53..25eca6d811 100644 --- 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 @@ -4,4 +4,4 @@ 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. 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. +- 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/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 929143fef0..a6f762c350 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 @@ -88,6 +88,7 @@ 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. // `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 @@ -125,7 +126,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 = [] }) { 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) => @@ -227,7 +228,12 @@ 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: branch.at(-1) } }; 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 (offBranch.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 c0cdb36e41..d9286a7efd 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 @@ -303,10 +303,27 @@ 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/. + 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); 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 2ad1eaa6eb..d6850bc27f 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,26 @@ 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 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({ @@ -142,9 +162,9 @@ test("a lane-enqueued merge passes only when its unedited lane pin names headRef 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 = {}) => [pr(1505, "2026-09-01T00:00:00Z", { labels: [MECHANICAL], timeline: enqueue, patch: { autoMerge, ...extra } })]; - const judge = (autoMerge, extra, lanes = [LANE]) => { - const result = run({ pulls: pulls(autoMerge, extra), workflowRuns: verifiedEach(pulls(autoMerge, extra)), comments: { 1505: [] } }, { laneLogins: lanes }); + 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")]; @@ -156,6 +176,10 @@ test("a lane identity's native auto-merge owes no pin, as in native_auto_merge", 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); 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 f507b7ca61..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 @@ -1023,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) 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 0d0efbc9a6..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 @@ -534,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 From b1ee5b9160be8fc3b26b83d4d49cb2939562f779 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:34:15 -0400 Subject: [PATCH 8/9] perf(source-control): read a run's ancestry last in the lane re-run probe Only a run the probe could mark pays for the ancestry read, so an unreadable compare on an old or already re-run run no longer ends the probe for newer runs. Co-Authored-By: Claude Opus 5.5 --- .../skills/babysit-prs/scripts/babysit_postmerge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 18b4b61da4..d018bb9115 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_postmerge.py @@ -1733,11 +1733,13 @@ def _lane_reruns(ctx: _Context, now: datetime, tally: _Tally) -> list[str]: ) for run in sorted(runs, key=lambda r: r.created_at, reverse=True): if ( - ctx.on_branch(run) - and run.id not in tally.reruns + run.id not in tally.reruns 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( From 62fdc202db1868ea9fb2fdba33dfd1a8181c884a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:41:52 -0400 Subject: [PATCH 9/9] test(source-control): cover promotion-evidence on-branch refusals and a behind run Each test fails when its path is removed: - the missing-tip refusal; - the unknown-compare-status refusal; - a red run on a commit on top of the tip (compare `behind`), which a tag named main one commit past the branch reads as, being ignored. The test fake gains `tip`, `onTop` and `unknownCompare` options. A comment records that the tip is read before the run listings here and after them in check(). Co-Authored-By: Claude Opus 5.5 --- .../github-fake.mjs | 11 +++++--- .../scripts/publish-promotion-evidence.mjs | 2 ++ .../publish-promotion-evidence.test.mjs | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) 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 a6f762c350..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 @@ -89,6 +89,9 @@ export const wfRun = (id, event, sha, created, { conclusion = "success", status // 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 @@ -126,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 = [], offBranch = [] }) { +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) => @@ -228,11 +231,13 @@ 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: branch.at(-1) } }; + 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 (offBranch.includes(m[2])) return { ...REC.compare, status: "ahead", ahead_by: 1, total_commits: 1, commits: [] }; + 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}`); 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 d9286a7efd..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 @@ -305,6 +305,8 @@ function postMergeRuns(gh, repo, branch, merges, { lane, throughMs }) { 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`); 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 d6850bc27f..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 @@ -80,6 +80,33 @@ test("a red run on a commit off the branch is not charged, as in _Context.on_bra 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({