Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/fix-pin-before-native-arm-2b8e5d17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `enable-auto-merge` posts the gate's `<!-- babysit-merge-pin sha=<head> -->` comment before it arms a lanes App pull request, unless the App's newest pin already names that head unedited. When the pin fails to post, the pull request stays unarmed and the run fails. Arming a pull request that is already mergeable enqueues it at once with no auto-merge event, so the post-merge check now reads that head back against the pin.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- The post-merge check counts a lanes App merge as native when arming an already mergeable pull request enqueued it at once, so GitHub recorded no auto-merge event. It counts the merge only when every enqueue on the pull request is the identity's and `merge.lane-prs` was `native` at the merge commit's parent. Such a merge no longer waits on a pin the gate never posts, and no longer turns into a false read-back demotion after six hours. A human enqueue, `merge.lane-prs: gate`, or an unreadable config keeps the pin read-back. The babysit-prs safety reference describes this rule, the pin `enable-auto-merge` now posts before it arms, and the residual.
37 changes: 35 additions & 2 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@
// so the person removes it after fixing the reason. Holds a person already
// set (labels, a body hold, a veto), the room and failed reads write nothing.
// Arming passes the head just read as expectedHeadOid, so a push in between
// fails the arm rather than arming a head nobody read. An armed pull request
// fails the arm rather than arming a head nobody read. Before it arms, the run
// posts the gate's pin comment for that head (pinBody) unless the lanes App's
// newest pin already names it unedited, and a pin that fails to post leaves
// the pull request unarmed: an arm GitHub enqueues at once records no
// auto-merge event, so the post-merge check reads that head back against the
// pin. An armed pull request
// that fails any rule is disarmed. Each request opens its own connection, and
// a read or write that fails on the network is sent up to twice more
// (withRetry). One whose arm or disarm still fails is left as it was, named
Expand Down Expand Up @@ -147,7 +152,7 @@ export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: Strin
export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
comments(last: 100) { totalCount nodes { databaseId author { __typename login } body createdAt updatedAt authorAssociation } }
comments(last: 100) { totalCount nodes { databaseId author { __typename login } body createdAt updatedAt lastEditedAt authorAssociation } }
reviews(last: 100) { totalCount nodes { author { __typename login } body submittedAt authorAssociation } }
reviewThreads(last: 100) {
totalCount
Expand Down Expand Up @@ -349,6 +354,23 @@ export function refusalBody(person, reason, head, label) {
].join("\n");
}

// The pin the post-merge read-back checks the merged head against
// (babysit_postmerge.pin_comment and PIN_RE): arming a pull request that is
// already mergeable enqueues it at once with no auto-merge event, which the
// read-back judges as an identity enqueue, so the head is pinned first.
export const PIN_RE = /^<!-- babysit-merge-pin sha=([0-9a-f]{40}) -->$/;
export const pinBody = (sha) => `<!-- babysit-merge-pin sha=${sha} -->\nThe unattended merge path armed native auto-merge at head \`${sha}\`.`;

// Whether the read-back would already read `head` from `comments`
// (DETAIL_QUERY's): the lanes App's newest pin names it and is unedited.
export function pinned(comments, bot, head) {
const pin = (comments ?? [])
.filter((c) => c?.author?.__typename === "Bot" && c.author.login === bot && PIN_RE.test(String(c.body ?? "").split("\n", 1)[0].replace(/\r$/, "")))
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
.at(-1);
return pin !== undefined && !pin.lastEditedAt && PIN_RE.exec(String(pin.body).split("\n", 1)[0].replace(/\r$/, ""))[1] === head;
}

// The lanes App's newest refusal comment in `comments` (DETAIL_QUERY's).
function priorRefusal(comments, bot) {
return (comments ?? [])
Expand Down Expand Up @@ -718,6 +740,17 @@ export async function main({ env = process.env, github, verify, room, sleep = (m
await note(pull, detail, { clears, reason });
continue;
}
if (action === "arm" && !pinned(detail?.comments?.nodes, slug, pull.headRefOid)) {
try {
await github("POST", `/repos/${repo[0]}/issues/${pull.number}/comments`, { body: pinBody(pull.headRefOid) });
console.log(`enable-auto-merge: #${pull.number} pinned at ${pull.headRefOid}`);
} catch (error) {
// No pin, no arm: an immediate enqueue would have no read-back.
failed += 1;
console.log(`::error title=enable-auto-merge::#${pull.number} pin, so not armed: ${describe(error)}`);
continue;
}
}
try {
if (action === "arm") {
await github("POST", "/graphql", { query: ARM_MUTATION, variables: { id: pull.id, head: pull.headRefOid } });
Expand Down
61 changes: 57 additions & 4 deletions .github/scripts/pr-merge/enable-auto-merge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name

// armErrors: per pull request id, the errors its arm sends throw, in order.
// filesError: what every changed-files GET throws.
function world(pulls, { failArm = false, armErrors = {}, filesError = null, noteErrors = [], disarmError = null } = {}) {
function world(pulls, { failArm = false, armErrors = {}, filesError = null, noteErrors = [], disarmError = null, pinError = null } = {}) {
const pins = [];
const noteThrows = [...noteErrors];
const armThrows = Object.fromEntries(Object.entries(armErrors).map(([id, errors]) => [id, [...errors]]));
const calls = [];
Expand All @@ -117,6 +118,14 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
if (method === "GET" && path === "/repos/melodic-software/claude-code-plugins/issues/comments/7") {
return COST_COMMENT;
}
// The read-back pin, posted before an arm.
if (method === "POST" && String(body?.body ?? "").startsWith("<!-- babysit-merge-pin ")) {
pins.push({ path, body: body.body });
if (pinError) {
throw pinError;
}
return {};
}
// The refusal note: a comment posted or edited, and the label.
const note = /^\/repos\/melodic-software\/claude-code-plugins\/issues\/(?:([0-9]+)\/(comments|labels)|comments\/([0-9]+))$/.exec(path);
if (note && (method === "POST" || method === "PATCH")) {
Expand Down Expand Up @@ -172,18 +181,18 @@ function world(pulls, { failArm = false, armErrors = {}, filesError = null, note
calls
.filter(({ method, path, body }) => method === "POST" && path === "/graphql" && ![PULLS_QUERY, DETAIL_QUERY, PULL_OPENED_QUERY, COMMENT_EDITED_QUERY, UNLABELED_QUERY].includes(body.query))
.map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables }));
return { github, writes, verify, verified, calls, notes };
return { github, writes, verify, verified, calls, notes, pins };
}

async function run(pulls, env = {}, options = {}, lane = ROOM) {
const { github, writes, verify, verified, calls, notes } = world(pulls, options);
const { github, writes, verify, verified, calls, notes, pins } = world(pulls, options);
const lines = [];
const slept = [];
const log = console.log;
console.log = (line) => lines.push(line);
try {
const code = await main({ env: { ...ENV, ...env }, github, verify, room: room(lane), sleep: async (ms) => slept.push(ms), random: options.random, now: options.now });
return { code, writes: writes(), lines, verified, slept, calls, notes };
return { code, writes: writes(), lines, verified, slept, calls, notes, pins };
} finally {
console.log = log;
}
Expand Down Expand Up @@ -341,6 +350,50 @@ test("a lanes App pull request with no hold is armed, squash, at the head it rea
assert.match(ARM_MUTATION, /mergeMethod: SQUASH, expectedHeadOid: \$head/);
});

// babysit_postmerge.PIN_RE, written out: the read-back takes the head from the
// first line of the lanes App's newest unedited pin comment.
const PIN_LINE = `<!-- babysit-merge-pin sha=${HEAD} -->`;

test("the head is pinned in the read-back's format before the arm", async () => {
const { code, writes, pins, calls } = await run([pull(7)]);
assert.equal(code, 0);
assert.deepEqual(pins.map((pin) => [pin.path, pin.body.split("\n")[0]]), [[`${ISSUES}/7/comments`, PIN_LINE]]);
assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]);
const order = calls.map((call) => (call.body?.query === ARM_MUTATION ? "arm" : String(call.body?.body ?? "").startsWith(PIN_LINE) ? "pin" : null)).filter(Boolean);
assert.deepEqual(order, ["pin", "arm"]);
});

test("an unedited pin for the same head is not posted again", async () => {
const pin = { ...lanesSaid(`${PIN_LINE}\nArmed.`), lastEditedAt: null };
const { writes, pins } = await run([pull(7, { discussion: { comments: [pin] } })]);
assert.deepEqual(pins, []);
assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]);
});

test("a pin for another head, an edited pin or another author's pin is posted again", async () => {
const other = { ...lanesSaid(`<!-- babysit-merge-pin sha=${"c".repeat(40)} -->`), lastEditedAt: null };
const edited = { ...lanesSaid(`${PIN_LINE}\nArmed.`), lastEditedAt: "2026-10-23T11:00:00Z" };
const person = { ...said("kyle-sexton", PIN_LINE, "2026-10-23T10:00:00Z"), lastEditedAt: null };
const stale = [{ ...lanesSaid(PIN_LINE), lastEditedAt: null }, { ...other, createdAt: "2026-10-23T12:00:00Z" }];
for (const comments of [[other], [edited], [person], stale]) {
const { pins } = await run([pull(7, { discussion: { comments } })]);
assert.equal(pins.length, 1);
}
});

test("a pin that fails to post leaves it unarmed and fails the run", async () => {
const { code, writes, pins, lines } = await run([pull(7), pull(8)], {}, { pinError: new Error("POST comments failed with status 502") });
assert.equal(code, 1);
assert.equal(pins.length, 2);
assert.deepEqual(writes, []);
assert.ok(lines.some((line) => line.startsWith("::error title=enable-auto-merge::#7 pin, so not armed")));
});

test("report-only posts no pin", async () => {
const { pins } = await run([pull(7)], { REPORT_ONLY: "true" });
assert.deepEqual(pins, []);
});

test("a Hold: line, do-not-merge or the human-gated label leaves it unarmed", async () => {
const { code, writes } = await run([
pull(1, { body: "Closes #5\n\nHold: a human merges this\n" }),
Expand Down
14 changes: 12 additions & 2 deletions plugins/source-control/skills/babysit-prs/reference/safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -1105,8 +1105,18 @@ 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, needs no read-back: it
verifies on the dispatch and canary runs alone.
- Arming a pull request that is already mergeable enqueues it at once, and GitHub records only
the identity's `AddedToMergeQueueEvent`, no auto-merge event. `enable-auto-merge.mjs` therefore
posts the gate's pin for the head before it arms, and arms nothing when the pin fails to post,
so such a merge reads back against its pin, and a push after the arm fails the read-back. A
merge of that shape with no pin is still native when every enqueue on the pull request is the
identity's, the read holds the whole enqueue history, and `merge.lane-prs` is `native` at the
merge commit's first parent; a config that cannot be read there keeps the read-back. Even then,
an unedited identity pin that stands is read back: a match verifies and a mismatch fails. Only
a merge of that shape with no unedited pin skips the read-back, and since pin-before-arm that
means one armed before it shipped, or one whose pin was edited or deleted after the enqueue.
- 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,13 @@ class PipelineConfig:
# (`babysit_postmerge`). Absent, or anything but a whole number from 0 to
# `MAX_CONFIRM_WAIT_MINUTES`, is `CONFIRM_WAIT_MINUTES`.
confirm_wait_minutes: int = CONFIRM_WAIT_MINUTES
# `merge.lane-prs`: `native` (absent from a readable pipeline file, as
# resolve-config resolves it) or `gate`; None for no file, a parse error
# or any other value (`babysit_postmerge.native_auto_merge`).
lane_prs: str | None = None


LANE_PRS_MODES = ("native", "gate")


# The most `merge.max-queue-ejections` may be, and the timeline items the
Expand Down Expand Up @@ -761,6 +768,7 @@ def file_at(path: str, sha: str) -> str | None:
("adoption.spec-from-body-apps", True),
("merge.quarantine-days", False),
("red-main.confirm-wait-minutes", False),
("merge.lane-prs", False),
],
)
if values is None:
Expand Down Expand Up @@ -884,6 +892,10 @@ def switch(key: str) -> bool:
f"from 0 to {MAX_CONFIRM_WAIT_MINUTES}"
)
confirm_wait = None
lane_prs: str | None = scalar("merge.lane-prs") or "native"
if lane_prs not in LANE_PRS_MODES:
notes.append(f"merge.lane-prs {lane_prs!r} is not native or gate")
lane_prs = None
return PipelineConfig(
base_sha=base_sha,
rung=rung,
Expand Down Expand Up @@ -913,6 +925,7 @@ def switch(key: str) -> bool:
confirm_wait_minutes=int(confirm_wait)
if confirm_wait is not None
else CONFIRM_WAIT_MINUTES,
lane_prs=lane_prs,
)


Expand Down
Loading
Loading