Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0c6e337
fix(pr-merge): require a pr-verify pass before a lane pull request me…
kyle-sexton Oct 23, 2026
b327760
chore(pr-merge): add changelog fragments for the native-merge verdict…
kyle-sexton Oct 23, 2026
09c0c85
test(pr-merge): run the step's own jq filter and tie verdictHold's wo…
kyle-sexton Oct 23, 2026
e6cbfb3
Merge remote-tracking branch 'origin/main' into fix/native-merge-requ…
kyle-sexton Oct 23, 2026
91e9eb4
test(pr-merge): pin enable-auto-merge's actions read and verdict read…
kyle-sexton Oct 23, 2026
ca7ab6e
fix(pr-merge): read the full verdict reader only on the scheduled run
kyle-sexton Oct 23, 2026
818577c
Merge branch 'main' into fix/native-merge-requires-verdict
kyle-sexton Oct 23, 2026
330060d
Merge remote-tracking branch 'origin/fix/native-merge-requires-verdic…
kyle-sexton Oct 23, 2026
b74356c
fix(pr-merge): end the verdict lookup only at a pass pr-verify uploaded
kyle-sexton Oct 23, 2026
6948bfc
fix(pr-merge): arm only on the gate's reader, and disarm on each push
kyle-sexton Oct 23, 2026
f69d8b0
test(source-control): classify the evidence CLI as a read-only entry …
kyle-sexton Oct 23, 2026
f4c1dfd
Merge remote-tracking branch 'origin/main' into fix/native-merge-requ…
kyle-sexton Oct 23, 2026
85eb917
fix(pr-merge): judge the reader's pass as the gate does, and fit its …
kyle-sexton Oct 23, 2026
5bdd428
Merge remote-tracking branch 'origin/main' into fix/native-merge-requ…
kyle-sexton Oct 23, 2026
60979e8
fix(pr-merge): warn on a failed push disarm, and pin the run-id early…
kyle-sexton Oct 23, 2026
7400f94
Merge remote-tracking branch 'origin/main' into fix/native-merge-requ…
kyle-sexton Oct 23, 2026
ee104a6
fix(pr-merge): mark the push disarm mutation gh-read-exempt
kyle-sexton Oct 23, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- Native auto-merge of a lanes App pull request now needs the verifier verdict pr-merge's gate needs. `enable-auto-merge.mjs` arms, and keeps armed, only when pr-merge's gate reader refuses nothing at the head it read (a pr-verify pass, no FAIL at the head, FAILs under `merge.max-verify-fails`, an unchanged trusted spec), and only on its scheduled or hand-started run; event runs never arm. `ci-status` turns an armed lanes App pull request's auto-merge off on each push to its head. In the merge queue, `ci-status` requires a pass or pass-notes artifact at the queued head from a successful pr-verify run on main; a trusted approval of the head lifts it. Pull request events read no verdict, since pr-verify runs only after a green pull request run.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Added

- `babysit_verify_evidence.py evidence --repo R [--max-fails N] <pr> <head>` prints the gate's verifier evidence for one pull request as one JSON line, which the pr-merge lane's native auto-merge arms on.
106 changes: 97 additions & 9 deletions .github/scripts/pr-merge/enable-auto-merge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@
// group, so a push after arming is held without waiting for this job.
// - No person turned its auto-merge off: the newest auto-merge-disabled event
// by a user account keeps it off until a person turns it on again.
// - pr-merge's gate would take its verifier verdict at the head just read:
// babysit_verify_evidence.py `evidence` refuses nothing (verifyHold), so a
// pr-verify PASS or PASS-NOTES stands at that head, no FAIL is at it, no
// earlier FAIL is uncleared, FAILs sit at fewer than merge.max-verify-fails
// heads, and the spec is unchanged and trusted. Only the scheduled run and
// a hand-started one read it (READER_EVENTS), last, for a pull request
// every other rule would arm or keep armed, sharing one artifact listing
// and run cache in the run. An event run (the issue lane, a person's edit,
// unlabel or ready) never arms and reads no verdict: it only disarms on a
// hold, so a pull request waits up to one scheduled interval to be armed.
// ci-status disarms an armed lanes App pull request on each push to its
// head, so a new head is armed only after the reader judges it, and in the
// merge queue it checks the pass again at the head it merges
// (merge-holds.mjs verdictHold).
// 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
// that fails any rule is disarmed. While merge.report-only is true it reports
Expand All @@ -38,7 +52,12 @@
// TRACKER_CONFIG the base .work-item-tracker.json, for the human-gated
// label (default .base/.work-item-tracker.json)
// RESOLVED_CONFIG the resolve-config output, for the hold paths
// EVENT_NAME the run's event; only READER_EVENTS read verdicts and arm
// BABYSIT_VERIFY_*, BABYSIT_RUN_CACHE the reader's Actions token (the
// job's GITHUB_TOKEN), saved listing, head-ref map and
// run cache, as the gate's
// GITHUB_API_URL
import { spawnSync } from "node:child_process";
import process from "node:process";
import { pathToFileURL } from "node:url";

Expand All @@ -61,13 +80,14 @@ const MAX_PAGES = 10;
// Changed files read per pull request, 100 a page; more is a head the run
// does not arm on.
const MAX_FILE_PAGES = 3;
const VERIFY_SCRIPT = ".base/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py";

export const PULLS_QUERY = `query($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, baseRefName: "main", first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
id number isDraft isCrossRepository headRefOid body
id number isDraft isCrossRepository headRefOid baseRefOid body
author { __typename login }
labels(first: 100) { totalCount nodes { name } }
autoMergeRequest { enabledAt }
Expand Down Expand Up @@ -158,9 +178,51 @@ async function changedFiles(github, slug, number) {
return null;
}

// Why the gate's verifier `evidence` (babysit_verify_evidence.py's report)
// keeps a pull request at `head` from merging, or null when it allows it.
export function verifyHold(evidence, head) {
if (evidence === null || typeof evidence !== "object" || !("refusal" in evidence)) {
return "its verifier verdict could not be read";
}
if (evidence.head !== head) {
return "its verifier verdict was read at another head";
}
return evidence.refusal === null ? null : `pr-merge's gate refuses its verifier verdict (${escapeCommand(evidence.refusal)})`;
}

// The gate's own reader, one process per pull request; the run shares its
// artifact listing, head-ref map and run cache through the BABYSIT_* files.
export function pythonVerify(env) {
const verify = async ({ number, headRefOid: head, baseRefOid: base }) => {
if (!SHA.test(String(base))) {
throw new Error("its base commit is not a commit SHA");
}
// --base-sha: the gate's FAIL cap, the lower of the base's and main's.
const args = [env.VERIFY_EVIDENCE_SCRIPT || VERIFY_SCRIPT, "evidence", "--repo", env.GITHUB_REPOSITORY, "--base-sha", base];
const done = spawnSync("python3", [...args, String(number), head], { encoding: "utf8", env, timeout: VERIFY_TIMEOUT_MS });
if (done.status !== 0) {
throw new Error(`babysit_verify_evidence.py exited ${done.status ?? done.signal}: ${String(done.stderr ?? "").trim().split("\n").at(-1)}`);
}
return JSON.parse(String(done.stdout).trim().split("\n").at(-1));
};
return verify;
}

// The runs that read the verifier verdict and so may arm: the schedule, and a
// hand-started run, which a person starts to retry a pull request that looks
// stuck.
export const READER_EVENTS = Object.freeze(["schedule", "workflow_dispatch"]);
// The reader's per pull request limit, and when the run stops starting one:
// a read started at the budget ends by VERIFY_BUDGET_MS + VERIFY_TIMEOUT_MS,
// 9 minutes, inside the step's 10 (pr-merge.yml), so no read is cut off and
// the rest of the pull requests are left for the next run.
export const VERIFY_TIMEOUT_MS = 4 * 60_000;
export const VERIFY_BUDGET_MS = 5 * 60_000;

Comment thread
kyle-sexton marked this conversation as resolved.
// What to do with one listed pull request: { action: arm | disarm | none, reason }.
// `detail` is its discussion; undefined means not read yet, and an arm or a
// keep then waits on it (action `read`).
// keep then waits on it (action `read`). Its `verify` is the gate's verifier
// evidence; absent, an arm or a keep waits on it (action `verify`).
export function decide(pull, { bot, humanGated, holdPaths = null }, detail) {
if (pull.author?.__typename !== "Bot" || pull.author.login !== bot) {
return { action: "none", reason: "not the lanes App's" };
Expand All @@ -187,13 +249,17 @@ export function decide(pull, { bot, humanGated, holdPaths = null }, detail) {
if (vetoed !== null) {
return { action: armed ? "disarm" : "none", reason: vetoed };
}
if (armed) {
return { action: "none", reason: "already armed" };
}
if (detail.timelineItems?.nodes?.at(-1)?.actor?.__typename === "User") {
if (!armed && detail.timelineItems?.nodes?.at(-1)?.actor?.__typename === "User") {
return { action: "none", reason: "a person turned its auto-merge off" };
}
return { action: "arm", reason: "no hold" };
if (!("verify" in detail)) {
return { action: "verify", reason: "its verifier verdict is not read yet" };
}
const unverified = verifyHold(detail.verify, pull.headRefOid);
if (unverified !== null) {
return { action: armed ? "disarm" : "none", reason: unverified };
}
return armed ? { action: "none", reason: "already armed" } : { action: "arm", reason: "no hold" };
}

async function listPulls(github, owner, name) {
Expand All @@ -214,7 +280,7 @@ async function listPulls(github, owner, name) {
throw new Error(`more than ${MAX_PAGES * 100} open pull requests into main`);
}

export async function main({ env = process.env, github } = {}) {
export async function main({ env = process.env, github, verify } = {}) {
try {
const repo = REPO.exec(env.GITHUB_REPOSITORY ?? "");
if (!repo) {
Expand All @@ -232,12 +298,18 @@ export async function main({ env = process.env, github } = {}) {
const humanGated = humanGatedLabelFrom(env.TRACKER_CONFIG || ".base/.work-item-tracker.json");
const holdPaths = readHoldPaths(env.RESOLVED_CONFIG ?? "");
github ??= createGitHub({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL || undefined });
verify ??= pythonVerify(env);
const reads = READER_EVENTS.includes(env.EVENT_NAME);
const pulls = await listPulls(github, repo[1], repo[2]);
const deadline = Date.now() + VERIFY_BUDGET_MS;
let failed = 0;
for (const pull of pulls) {
let { action, reason } = decide(pull, { bot: slug, humanGated, holdPaths });
let detail = null;
if (action === "read" && !reads && pull.autoMergeRequest == null) {
({ action, reason } = { action: "none", reason: "only a scheduled or hand-started run arms it, on the gate's verifier verdict" });
}
if (action === "read") {
let detail = null;
try {
const variables = { owner: repo[1], name: repo[2], number: pull.number };
detail = (await github("POST", "/graphql", { query: DETAIL_QUERY, variables }))?.data?.repository?.pullRequest ?? null;
Expand All @@ -249,6 +321,22 @@ export async function main({ env = process.env, github } = {}) {
}
({ action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }, detail));
}
if (action === "verify" && !reads) {
({ action, reason } =
pull.autoMergeRequest != null
? { action: "none", reason: "already armed; the next scheduled run checks its verifier verdict" }
: { action: "none", reason: "only a scheduled or hand-started run arms it, on the gate's verifier verdict" });
} else if (action === "verify" && Date.now() > deadline) {
({ action, reason } = { action: "none", reason: "no time left in this run to read its verifier verdict" });
} else if (action === "verify") {
let evidence = null;
try {
evidence = await verify(pull);
Comment thread
kyle-sexton marked this conversation as resolved.
} catch (error) {
console.log(`enable-auto-merge: #${pull.number} verifier verdict did not read: ${escapeCommand(error?.message ?? error)}`);
}
({ action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }, { ...detail, verify: evidence }));
}
if (action === "none") {
if (reason !== "not the lanes App's") {
console.log(`enable-auto-merge: #${pull.number} left: ${reason}`);
Expand Down
Loading
Loading