diff --git a/.changes/pr-pipeline/fix-native-merge-requires-verdict-7c41e2a9.md b/.changes/pr-pipeline/fix-native-merge-requires-verdict-7c41e2a9.md new file mode 100644 index 0000000000..bdf0b05d78 --- /dev/null +++ b/.changes/pr-pipeline/fix-native-merge-requires-verdict-7c41e2a9.md @@ -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. diff --git a/.changes/source-control/fix-native-merge-requires-verdict-3d9b06f1.md b/.changes/source-control/fix-native-merge-requires-verdict-3d9b06f1.md new file mode 100644 index 0000000000..17181be53c --- /dev/null +++ b/.changes/source-control/fix-native-merge-requires-verdict-3d9b06f1.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Added + +- `babysit_verify_evidence.py evidence --repo R [--max-fails N] ` 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. diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs index c20fbab94c..c764ce748c 100755 --- a/.github/scripts/pr-merge/enable-auto-merge.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -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 @@ -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"; @@ -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 } @@ -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; + // 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" }; @@ -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) { @@ -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) { @@ -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; @@ -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); + } 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}`); diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs index 9dba8db3ad..98f7e73f22 100644 --- a/.github/scripts/pr-merge/enable-auto-merge.test.mjs +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -14,13 +14,27 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; -import { ARM_MUTATION, BODY_HOLD, DETAIL_QUERY, DISARM_MUTATION, HUMAN_VETO, PULLS_QUERY, decide, main } from "./enable-auto-merge.mjs"; +import { + ARM_MUTATION, + BODY_HOLD, + DETAIL_QUERY, + DISARM_MUTATION, + HUMAN_VETO, + PULLS_QUERY, + decide, + READER_EVENTS, + main, + verifyHold, +} from "./enable-auto-merge.mjs"; +import { VERIFY_PATH, verdictHold } from "./merge-holds.mjs"; +import { VERIFY_PATH as RESOLVER_VERIFY_PATH } from "../pr-verify/resolve-pr.mjs"; import { decide as decideOutcome } from "../work-resolve-issue/report-outcome.mjs"; const REPO = "melodic-software/claude-code-plugins"; const LANES = { __typename: "Bot", login: "melodic-automation-lanes" }; const HEAD = "b".repeat(40); const ENV = { + EVENT_NAME: "schedule", GITHUB_REPOSITORY: REPO, KILL_SWITCH: "proceed", REPORT_ONLY: "false", @@ -35,6 +49,7 @@ const pull = (number, overrides = {}) => ({ isDraft: false, isCrossRepository: false, headRefOid: HEAD, + baseRefOid: "d".repeat(40), body: `Closes #${number + 1000}\n\n## Summary\n\nDone.`, author: LANES, labels: { totalCount: 0, nodes: [] }, @@ -58,6 +73,7 @@ const said = (login, body, at, association = "OWNER", typename = "User") => ({ createdAt: at, authorAssociation: association, }); +const PASSED = { refusal: null, head: HEAD }; const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name) => ({ name })) }); function world(pulls, { failArm = false } = {}) { @@ -83,21 +99,32 @@ function world(pulls, { failArm = false } = {}) { } return { data: {} }; }; + // The gate's verifier evidence: a pull request's `verdict` (a report, or + // an Error to throw), else a pass at its head. + const verified = []; + const verify = async ({ number, headRefOid: head }) => { + verified.push(number); + const found = pulls.find((candidate) => candidate.number === number); + if (found?.verdict instanceof Error) { + throw found.verdict; + } + return found?.verdict ?? { refusal: null, head }; + }; const writes = () => calls .filter(({ method, body }) => method === "POST" && body.query !== PULLS_QUERY && body.query !== DETAIL_QUERY) .map(({ body }) => ({ mutation: body.query === ARM_MUTATION ? "arm" : body.query === DISARM_MUTATION ? "disarm" : "?", ...body.variables })); - return { github, writes }; + return { github, writes, verify, verified }; } async function run(pulls, env = {}, options = {}) { - const { github, writes } = world(pulls, options); + const { github, writes, verify, verified } = world(pulls, options); const lines = []; const log = console.log; console.log = (line) => lines.push(line); try { - const code = await main({ env: { ...ENV, ...env }, github }); - return { code, writes: writes(), lines }; + const code = await main({ env: { ...ENV, ...env }, github, verify }); + return { code, writes: writes(), lines, verified }; } finally { console.log = log; } @@ -242,7 +269,7 @@ test("report-outcome's own pull request body arms, and its hold and Refs bodies const opened = plan(overrides); assert.equal(opened.action, "open-pr"); // Files outside the hold paths, so only the body decides. - return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human", holdPaths: [".github/**"] }, { ...detail(), files: [] }); + return decide(pull(1, { body: opened.body }), { bot: "melodic-automation-lanes", humanGated: "needs-human", holdPaths: [".github/**"] }, { ...detail(), files: [], verify: PASSED }); }; assert.deepEqual(verdict({}), { action: "arm", reason: "no hold" }); assert.equal(verdict({ humanMerge: true }).action, "none"); @@ -282,7 +309,7 @@ test("an armed pull request a member now vetoes is disarmed", async () => { test("a pull request whose discussion cannot all be read is not armed", async () => { const cut = { totalCount: 101, nodes: [] }; const options = { bot: LANES.login, humanGated: "needs-human", holdPaths: [] }; - assert.equal(decide(pull(1), options, { ...detail(), files: [] }).action, "arm"); + assert.equal(decide(pull(1), options, { ...detail(), files: [], verify: PASSED }).action, "arm"); const held = (pr) => decide(pull(1), options, { ...detail(), files: [], ...pr }); assert.equal(held({ comments: cut }).action, "none"); assert.equal(held({ reviews: cut }).action, "none"); @@ -369,3 +396,166 @@ test("hold paths that cannot be read arm nothing", async () => { const { writes } = await run([pull(1)], { RESOLVED_CONFIG: "/nonexistent/resolved-config.json" }); assert.deepEqual(writes, []); }); + +// pr-merge's gate merges only on a pr-verify pass at the head, FAILs at fewer +// than merge.max-verify-fails heads (babysit_verify_evidence.py), and native +// auto-merge must not merge what the gate would refuse. +test("no pass at the head, a FAIL or the repeated-FAIL cap arms nothing", async () => { + const { code, writes, verified } = await run([ + pull(1, { verdict: { refusal: "verify:missing", head: HEAD } }), + pull(2, { verdict: { refusal: "verify:fail", head: HEAD } }), + pull(3, { verdict: { refusal: "verify:repeated-fail", head: HEAD, needsHuman: true } }), + pull(4, { verdict: { refusal: "verify:earlier-fail", head: HEAD } }), + pull(5, { verdict: { refusal: null, head: "c".repeat(40) } }), + pull(6, { verdict: new Error("gh: rate limited") }), + pull(7, { verdict: { refusal: "verify:unreadable", head: HEAD } }), + pull(8), + ]); + assert.equal(code, 0); + assert.deepEqual(verified, [1, 2, 3, 4, 5, 6, 7, 8]); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_8", head: HEAD }]); +}); + +test("an armed pull request whose head lost its pass, or passed the cap, is disarmed", async () => { + const armed = { enabledAt: "2026-10-23T10:00:00Z" }; + const { writes, lines } = await run([ + // Armed on a passing head, then pushed: the gate finds no verdict at the new head. + pull(1, { autoMergeRequest: armed, verdict: { refusal: "verify:missing", head: HEAD } }), + pull(2, { autoMergeRequest: armed, verdict: { refusal: "verify:repeated-fail", head: HEAD } }), + pull(3, { autoMergeRequest: armed, verdict: new Error("listing failed") }), + pull(4, { autoMergeRequest: armed }), + ]); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_1" }, + { mutation: "disarm", id: "PR_2" }, + { mutation: "disarm", id: "PR_3" }, + ]); + assert.ok(lines.some((line) => /#2 disarmed: pr-merge's gate refuses its verifier verdict \(verify:repeated-fail\)/.test(line))); +}); + +test("the verdict is read only for a lanes App pull request nothing else holds", async () => { + const { writes, verified } = await run([ + pull(1, { author: { __typename: "User", login: "kyle-sexton" } }), + pull(2, { labels: labels("do-not-merge") }), + pull(3, { isDraft: true }), + pull(4, { discussion: { comments: [said("kyle-sexton", "do not merge", "2026-10-23T10:00:00Z")] } }), + pull(5, { discussion: { disabledBy: { __typename: "User", login: "kyle-sexton" } } }), + ]); + assert.deepEqual(writes, []); + assert.deepEqual(verified, []); +}); + +test("verifyHold allows only the reader's null refusal at the head read", () => { + assert.equal(verifyHold(PASSED, HEAD), null); + assert.match(verifyHold({ refusal: null, head: "c".repeat(40) }, HEAD), /another head/); + assert.match(verifyHold(null, HEAD), /could not be read/); + assert.match(verifyHold({ head: HEAD }, HEAD), /could not be read/); + assert.match(verifyHold({ refusal: "verify:too-old", head: HEAD }, HEAD), /verify:too-old/); +}); + +// The merge queue's check (merge-holds.mjs verdictHold): a pass counts only +// from pr-verify.yml's completed, successful workflow_run run on main here. +test("verdictHold counts a pass only from a successful pr-verify run on main", () => { + const run = (overrides = {}) => ({ + id: 11, + path: VERIFY_PATH, + event: "workflow_run", + head_branch: "main", + status: "completed", + conclusion: "success", + repository: { full_name: REPO }, + head_repository: { full_name: REPO }, + ...overrides, + }); + const hold = (records) => verdictHold(Array.isArray(records) ? records.map((record) => ({ runId: 11, ...record })) : records, REPO); + assert.equal(hold([{ verdict: "pass", run: run() }]), null); + assert.equal(hold([{ verdict: "pass-notes", run: run() }]), null); + assert.equal(hold([{ verdict: "pass", run: run({ conclusion: "failure" }) }, { verdict: "pass-notes", run: run() }]), null); + for (const bad of [ + { path: ".github/workflows/other.yml" }, + { event: "pull_request" }, + { head_branch: "feature" }, + { status: "in_progress", conclusion: null }, + { conclusion: "failure" }, + { repository: { full_name: "fork/claude-code-plugins" } }, + { head_repository: { full_name: "fork/claude-code-plugins" } }, + ]) { + assert.match(hold([{ verdict: "pass", run: run(bad) }]), /has not passed this head/, JSON.stringify(bad)); + } + assert.match(hold([{ verdict: "fail", run: run() }]), /has not passed this head/); + // A record of another run than the one its artifact names counts nothing. + assert.match(hold([{ verdict: "pass", runId: 12, run: run() }]), /has not passed this head/); + assert.match(hold([{ verdict: "pass", runId: "11", run: run() }]), /has not passed this head/); + assert.match(hold([]), /has not passed this head/); + assert.match(hold(null), /could not be read/); + assert.match(verdictHold([], "not a repo"), /could not be read/); +}); + +// test_verify_rules_sync.py ties the resolver's path to the gate's WORKFLOW_PATH. +test("verdictHold names the verifier workflow the resolver and the gate name", () => { + assert.equal(VERIFY_PATH, RESOLVER_VERIFY_PATH); +}); + +// Only the gate's full reader arms, so native merge refuses what the gate +// refuses; an event run disarms on a hold and reads no verdict. +test("only the scheduled run and a hand-started one read the verdict and arm", () => { + assert.deepEqual([...READER_EVENTS], ["schedule", "workflow_dispatch"]); +}); + +test("an event run arms nothing and reads neither discussion nor verdict for an unarmed pull request", async () => { + for (const event of ["workflow_run", "pull_request_target"]) { + const { code, writes, verified, lines } = await run([pull(1)], { EVENT_NAME: event }); + assert.equal(code, 0); + assert.deepEqual(writes, []); + assert.deepEqual(verified, []); + assert.ok(lines.some((line) => /#1 left: only a scheduled or hand-started run arms it/.test(line)), event); + } +}); + +test("an event run still disarms an armed pull request that now holds", async () => { + const armed = { enabledAt: "2026-10-23T10:00:00Z" }; + const { writes, verified } = await run( + [ + pull(1, { autoMergeRequest: armed, labels: labels("do-not-merge") }), + pull(2, { autoMergeRequest: armed, discussion: { comments: [said("kyle-sexton", "do not merge", "2026-10-23T10:00:00Z")] } }), + pull(3, { autoMergeRequest: armed }), + ], + { EVENT_NAME: "pull_request_target" }, + ); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_1" }, + { mutation: "disarm", id: "PR_2" }, + ]); + assert.deepEqual(verified, []); +}); + +test("a reader that fails, prints no JSON or reports another head arms nothing and disarms", async () => { + const dir = mkdtempSync(join(tmpdir(), "enable-auto-merge-")); + const scripts = { + exits: "import sys\nsys.stderr.write('boom\\n')\nsys.exit(1)\n", + garbage: "print('not json')\n", + other: `import json\nprint(json.dumps({"refusal": None, "head": "${"c".repeat(40)}"}))\n`, + passes: `import json, sys\nassert sys.argv[-4:-2] == ["--base-sha", "${"d".repeat(40)}"], sys.argv\nprint(json.dumps({"refusal": None, "head": sys.argv[-1]}))\n`, + }; + for (const [name, body] of Object.entries(scripts)) { + writeFileSync(join(dir, `${name}.py`), body); + } + const armed = { enabledAt: "2026-10-23T10:00:00Z" }; + for (const [name, want] of [ + ["exits", [{ mutation: "disarm", id: "PR_2" }]], + ["garbage", [{ mutation: "disarm", id: "PR_2" }]], + ["other", [{ mutation: "disarm", id: "PR_2" }]], + ["passes", [{ mutation: "arm", id: "PR_1", head: HEAD }]], + ]) { + const { github, writes } = world([pull(1), pull(2, { autoMergeRequest: armed })]); + const env = { ...ENV, EVENT_NAME: "schedule", PATH: process.env.PATH, VERIFY_EVIDENCE_SCRIPT: join(dir, `${name}.py`) }; + const log = console.log; + console.log = () => {}; + try { + await main({ env, github }); + } finally { + console.log = log; + } + assert.deepEqual(writes(), want, name); + } +}); diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs index bc8dd278fc..6edc296313 100755 --- a/.github/scripts/pr-merge/merge-holds.mjs +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -16,9 +16,19 @@ // merge.diff-check.denied-paths plus HOLD_PATH_FLOOR. A person on the // trusted-actor list lifts it by approving the head: their newest review of // the head that approves, requests changes or was dismissed must approve. +// - No verifier pass, when a verdicts file is given (ci-status gives one in +// the merge queue only): the head merges on a PASS or PASS-NOTES that +// pr-verify.yml uploaded from a completed, successful run on main +// (verdictHold). The same approval of the head lifts it, the human merge +// path for a pull request pr-merge's gate sends to a person. FAILs and the +// repeated-FAIL cap are enable-auto-merge.mjs's, through pr-merge's own +// reader, which disarms such a pull request. // // Command line: merge-holds.mjs -// . The +// +// []. The verdicts file holds one {name, verdict, runId, run} +// per line, run as GET actions/runs/{runId} gives it, for each unexpired artifact +// named a pass at the head; GITHUB_REPOSITORY names the repository. The // discussion file holds one REST issue comment, review or review comment per // line; the files file one JSON array per changed file, its path then any // renamed-from path; the denied-paths file the config's @@ -157,12 +167,44 @@ export function holds(body, items, files, holdPaths, lifters = []) { return reasons; } +// The verifier workflow (resolve-pr.mjs VERIFY_PATH). +export const VERIFY_PATH = ".github/workflows/pr-verify.yml"; +const PASSES = Object.freeze(["pass", "pass-notes"]); + +// Why the head has no verifier pass, or null when a record shows one: a pass +// or pass-notes artifact whose run is pr-verify.yml's, event workflow_run, on +// `branch` of `repo` with its head in `repo`, completed with success: the +// fields of babysit_verify_evidence `_origin` and a counted run, without its +// compare read that main holds the run's commit, since main's ruleset +// forbids the force push that could drop it. The records are +// read for the head alone, by exact name, so a record of another head never +// reaches here. +export function verdictHold(records, repo, branch = "main") { + if (!Array.isArray(records) || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(String(repo))) { + return "its pr-verify verdicts could not be read"; + } + const pass = records.find( + ({ verdict, runId, run } = {}) => + PASSES.includes(verdict) && + Number.isSafeInteger(runId) && + run?.id === runId && + run.path === VERIFY_PATH && + run.event === "workflow_run" && + run.head_branch === branch && + run.repository?.full_name === repo && + run.head_repository?.full_name === repo && + run.status === "completed" && + run.conclusion === "success", + ); + return pass ? null : "pr-verify has not passed this head"; +} + // A log line can carry a changed file's name: escape what would start a // workflow command on a new line. const escape = (text) => String(text).replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A"); export function main(argv = process.argv.slice(2)) { - const [number, bodyFile, discussionFile, filesFile, deniedFile, actorsFile, head] = argv; + const [number, bodyFile, discussionFile, filesFile, deniedFile, actorsFile, head, verdictsFile] = argv; try { if ( !/^[1-9][0-9]*$/.test(String(number)) || @@ -174,7 +216,7 @@ export function main(argv = process.argv.slice(2)) { !/^[0-9a-f]{40}$/.test(String(head)) ) { throw new Error( - "usage: merge-holds.mjs ", + "usage: merge-holds.mjs []", ); } const lines = (file) => readFileSync(file, "utf8").split("\n").filter((line) => line.trim()); @@ -194,6 +236,18 @@ export function main(argv = process.argv.slice(2)) { if (held && lifters.length && Array.isArray(holdPaths)) { console.log(`#${number}: ${lifters.join(", ")} approved this head, which lifts its hold path (${escape(held)}).`); } + if (verdictsFile) { + const unverified = verdictHold(lines(verdictsFile).map((line) => JSON.parse(line)), process.env.GITHUB_REPOSITORY); + if (!unverified) { + console.log(`#${number}: no verifier hold: pr-verify passed this head.`); + } else if (lifters.length) { + console.log(`#${number}: no verifier hold: ${lifters.join(", ")} approved this head, which lifts '${unverified}'.`); + } else { + reasons.push( + `${unverified}; wait for pr-verify to pass it, after which enable-auto-merge arms it again, or an approving review of this head by a person on the trusted-actor list lifts it, then re-queue`, + ); + } + } for (const reason of reasons) { console.log(`::error::#${number}: ${escape(reason)}.`); } diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 40cd2edfd8..713a75f129 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -12,6 +12,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { VERIFY_BUDGET_MS, VERIFY_TIMEOUT_MS } from "./enable-auto-merge.mjs"; + const read = (name) => readFileSync(new URL(`../../workflows/${name}`, import.meta.url), "utf8"); const merge = read("pr-merge.yml"); const jobs = merge.slice(merge.indexOf("\njobs:\n")); @@ -162,6 +164,13 @@ test("the issue lane finishing wakes only the enable-auto-merge job", () => { assert.match(arm, /\n {4}concurrency:\n {6}group: pr-merge-enable-auto-merge\n {6}queue: max\n/); }); +test("the last verifier read enable-auto-merge starts ends inside its step's timeout", () => { + const arm = job("enable-auto-merge"); + const step = /- name: Arm and disarm auto-merge\n {8}if: .*\n {8}timeout-minutes: ([0-9]+)\n/.exec(arm); + assert.ok(step, "the arm step names its timeout"); + assert.ok(VERIFY_BUDGET_MS + VERIFY_TIMEOUT_MS < Number(step[1]) * 60_000); +}); + test("enable-auto-merge asks the broker for its own activity's merge token", () => { const arm = job("enable-auto-merge"); const steps = [...arm.matchAll(/\n {10}lane: (.*)\n {10}activity: (.*)\n/g)].map((m) => `${m[1]}/${m[2]}`); @@ -177,8 +186,27 @@ test("enable-auto-merge asks the broker for its own activity's merge token", () const block = /\n {4}permissions:\n((?: {6}[a-z-]+: [a-z]+.*\n)+)/.exec(arm)[1]; assert.deepEqual( [...block.matchAll(/ {6}([a-z-]+): ([a-z]+)/g)].map((m) => `${m[1]}: ${m[2]}`), - ["contents: read", "id-token: write"], + ["contents: read", "actions: read", "id-token: write"], + ); + // The verifier evidence is read as the gate reads it: the job's + // GITHUB_TOKEN for the Actions reads, with the run's head-ref map and + // carried run cache. + for (const line of [ + "EVENT_NAME: ${{ github.event_name }}", + "BABYSIT_VERIFY_GH_TOKEN: ${{ github.token }}", + "BABYSIT_VERIFY_HEAD_REFS: ${{ runner.temp }}/head-refs-in.json", + "BABYSIT_RUN_CACHE: ${{ runner.temp }}/babysit-run-cache.json", + ]) { + assert.ok(arm.includes(`\n ${line}\n`), line); + } + assert.match(arm, /node \.base\/\.github\/scripts\/pr-merge\/head-ref-map\.mjs read\n/); + // Only the runs that read the verdict (READER_EVENTS) read the map. + assert.match( + arm, + /- name: Read the head refs the last gate run kept\n {8}if: steps\.lane-token\.outputs\.token != '' && \(github\.event_name == 'schedule' \|\| github\.event_name == 'workflow_dispatch'\)\n/, ); + // The reader resolves the FAIL cap itself, from the base and main (--base-sha). + assert.doesNotMatch(arm, /MAX_VERIFY_FAILS/); }); // The dispatch POST's retry, run under bash against a stub gh that answers diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index df71d096da..a0ca3eb371 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -1267,7 +1267,11 @@ jobs: # itself. The issue lane finishing arms the pull request it opened; a listed # human's edit, unlabel or ready disarms or arms at once; the schedule # catches what the events missed, such as a run the lanes App started, which - # the broker refuses. Its own job and token: the broker mints one token per + # the broker refuses. Only the scheduled run and a hand-started one arm, on + # the verdict pr-merge's gate would take at the head (babysit_verify_evidence.py, + # read with the job's GITHUB_TOKEN from the head-ref map and run cache the + # last gate run kept); event runs read no verdict and only disarm on a hold. + # Its own job and token: the broker mints one token per # job. Rules: .github/scripts/pr-merge/enable-auto-merge.mjs. enable-auto-merge: # An issue run that skipped every job (a label other than agent-ready) @@ -1283,9 +1287,10 @@ jobs: group: pr-merge-enable-auto-merge queue: max runs-on: ubuntu-24.04 - timeout-minutes: 10 + timeout-minutes: 15 permissions: contents: read # check out main + actions: read # the verifier verdicts, their runs and the head-ref map id-token: write # prove to the token broker which workflow this is steps: - name: Check out the default branch tip @@ -1359,15 +1364,50 @@ jobs: issues: ${{ steps.config.outputs.issues }} superseded-ok: true + # The verdict reader of a scheduled or hand-started run ties untied + # FAILs as the gate job does: the head-ref map and the carried run + # cache. Any error leaves none, and the reader reads GitHub instead. + # Event runs read no verdict and need neither. + - name: Read the head refs the last gate run kept + if: steps.lane-token.outputs.token != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + continue-on-error: true + env: + ACTIONS_TOKEN: ${{ github.token }} + HEAD_REFS_PATH: ${{ runner.temp }}/head-refs-in.json + run: node .base/.github/scripts/pr-merge/head-ref-map.mjs read + + - name: Restore the verdict reads the last gate run kept + if: steps.lane-token.outputs.token != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + continue-on-error: true + timeout-minutes: 2 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/pr-merge-carried.json + key: pr-merge-run-cache-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: pr-merge-run-cache- + + - name: Start the run cache from them + if: steps.lane-token.outputs.token != '' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + continue-on-error: true + env: + CARRY_FROM: ${{ runner.temp }}/pr-merge-carried.json + CARRY_TO: ${{ runner.temp }}/babysit-run-cache.json + run: node .base/.github/scripts/pr-merge/carry-run-cache.mjs + # While merge.report-only is anything but false it reports each # decision and writes nothing. - name: Arm and disarm auto-merge if: steps.lane-token.outputs.token != '' - timeout-minutes: 3 + timeout-minutes: 10 env: GH_TOKEN: ${{ steps.lane-token.outputs.token }} KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }} RESOLVED_CONFIG: ${{ runner.temp }}/resolved-config.json + EVENT_NAME: ${{ github.event_name }} + BABYSIT_VERIFY_GH_TOKEN: ${{ github.token }} + BABYSIT_VERIFY_LISTING: ${{ runner.temp }}/verify-listing.json + BABYSIT_VERIFY_HEAD_REFS: ${{ runner.temp }}/head-refs-in.json + BABYSIT_RUN_CACHE: ${{ runner.temp }}/babysit-run-cache.json run: | set -euo pipefail config=.base/docs/conventions/pr-pipeline.yaml diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index d2a28b5061..064a8ecb5b 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3110,7 +3110,8 @@ jobs: # of own-gate retries, 7 s of status-write retries and the 90 s # `rerun-wait-seconds` wait, about four and a half minutes, plus up to # 15 s of retry sleeps for each of the hold-lift step's two reads, the - # review-lane step's four and the lanes-hold step's eight, about four + # review-lane step's four and the lanes-hold step's eight (in the merge + # queue up to four verdict lookups and their run reads more), about four # minutes, plus the cost-hold step's reads: one for most pull requests, # which stop at the label events; for a lane-labeled one whose body cites a # decision, about eight, and two more per review run since the window @@ -3971,12 +3972,27 @@ jobs: # it); every other pull request merges through pr-merge's gate or a # person. An unreadable pull request, module, config, file list or # discussion fails closed. + # IN THE MERGE QUEUE A LANES APP PULL REQUEST ALSO NEEDS A VERIFIER PASS + # AT ITS HEAD: a PASS or PASS-NOTES artifact of a completed, successful + # pr-verify.yml run on main (merge-holds.mjs verdictHold), looked up by + # exact name, with and without the head-branch digest, stopping at the + # first name that holds such a run: 2 reads for a plain-named pass, at most 4 lookups plus + # one run read per artifact found. The same approval of the head lifts + # it. Only in the queue: pr-verify runs only after a successful + # pull_request run of this workflow, so a red ci-status there would + # never get a verdict, and a pull request event reads nothing for it. + # A push to an armed lanes App pull request turns its auto-merge off + # (one mutation, no read), so enable-auto-merge's reader judges each + # new head before it is armed again; the queue still checks the head + # it merges. + # FAILs and the repeated-FAIL cap are enable-auto-merge.mjs's. # Pinned by scripts/ci-lanes-pr-hold.test.sh, which runs this body. - name: Fail a lanes App pull request its body or a member holds if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} + EVENT_ACTION: ${{ github.event.action }} GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} run: | . "$RUNNER_TEMP/gh-read.sh" @@ -4020,6 +4036,24 @@ jobs: exit 0 fi BASE_SHA=$(jq -r '.base.sha' <<<"$pull") + # A push gives an armed pull request a head enable-auto-merge's + # reader has not judged: turn its auto-merge off until a reader + # run arms the new head. A failed mutation only warns: failing + # here would hold every push to an armed pull request red if this + # token cannot disarm. A missed disarm is backstopped by the merge + # queue's check for a pr-verify pass at the exact head it merges + # (below) and by enable-auto-merge's scheduled reader, which + # disarms a head it refuses within 15 minutes. + if [ "${EVENT_ACTION:-}" = synchronize ] && jq -e '.auto_merge != null' <<<"$pull" >/dev/null; then + # shellcheck disable=SC2016 # $id is a GraphQL variable + # gh-read-exempt: a GraphQL mutation, a write that is not retried; a failure only warns. + if gh api graphql -f query='mutation($id: ID!) { disablePullRequestAutoMerge(input: {pullRequestId: $id}) { clientMutationId } }' \ + -f id="$(jq -r '.node_id' <<<"$pull")" >/dev/null; then + echo "#$pr: a push moved its head, so its native auto-merge is off until enable-auto-merge's next scheduled run judges the new head." + else + echo "::warning::could not turn off native auto-merge on #$pr after a push to its head; the merge queue still needs a pr-verify pass at the head it merges, and the next scheduled enable-auto-merge run disarms a head its reader refuses." + fi + fi fi if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then echo "::error::the base commit '$BASE_SHA' is not a commit SHA." @@ -4056,14 +4090,45 @@ jobs: rows=$(read_api "the $kind of #$pr" "repos/$GITHUB_REPOSITORY/$kind?per_page=100" --paginate --jq '.[] | @json') || exit 1 [ -z "$rows" ] || printf '%s\n' "$rows" >>"$RUNNER_TEMP/discussion.jsonl" done + verdicts=() + if [ "$GITHUB_EVENT_NAME" = merge_group ]; then + ref=$(jq -r '.head.ref // ""' <<<"$pull") + if [ -z "$ref" ]; then + echo "::error::the head branch of #$pr could not be read." + exit 1 + fi + digest=$(printf '%s' "$ref" | sha256sum | cut -c1-16) + : >"$RUNNER_TEMP/verdicts.jsonl" + for name in "pr-verify-$pr-$head-pass" "pr-verify-$pr-$head-pass-$digest" \ + "pr-verify-$pr-$head-pass-notes" "pr-verify-$pr-$head-pass-notes-$digest"; do + verdict=${name#"pr-verify-$pr-$head-"} + verdict=${verdict%"-$digest"} + runs=$(read_api "the $name artifacts" "repos/$GITHUB_REPOSITORY/actions/artifacts?name=$name&per_page=100" \ + --jq ".artifacts[] | select(.name == \"$name\" and .expired == false) | .workflow_run.id") || exit 1 + for run in $runs; do + [[ $run =~ ^[1-9][0-9]*$ ]] || continue + record=$(read_api "run $run" "repos/$GITHUB_REPOSITORY/actions/runs/$run") || exit 1 + jq -c --arg name "$name" --arg verdict "$verdict" --argjson id "$run" '{name: $name, verdict: $verdict, runId: $id, run: .}' <<<"$record" >>"$RUNNER_TEMP/verdicts.jsonl" || exit 1 + done + # A pass merge-holds.mjs verdictHold would count ends the lookup. + ! jq -se --arg repo "$GITHUB_REPOSITORY" 'any(.runId as $id | .run | .id == $id and .status == "completed" and .conclusion == "success" + and .path == ".github/workflows/pr-verify.yml" and .event == "workflow_run" and .head_branch == "main" + and .repository.full_name == $repo and .head_repository.full_name == $repo)' "$RUNNER_TEMP/verdicts.jsonl" >/dev/null || break + done + verdicts=("$RUNNER_TEMP/verdicts.jsonl") + fi out=$(node "$RUNNER_TEMP/merge-holds.mjs" "$pr" "$RUNNER_TEMP/pr-body.txt" "$RUNNER_TEMP/discussion.jsonl" \ - "$RUNNER_TEMP/files.jsonl" "$RUNNER_TEMP/denied-paths.json" "$RUNNER_TEMP/trusted-actors.json" "$head") || { + "$RUNNER_TEMP/files.jsonl" "$RUNNER_TEMP/denied-paths.json" "$RUNNER_TEMP/trusted-actors.json" "$head" "${verdicts[@]}") || { printf '%s\n' "$out" exit 1 } # Exit 0 without the module's no-hold line ran no check: fail closed. printf '%s\n' "$out" grep -qxF "#$pr: no body hold, no member veto and no hold path." <<<"$out" || { echo "::error::merge-holds.mjs exited 0 without its verdict."; exit 1; } + if [ "${#verdicts[@]}" -gt 0 ] && ! grep -qE "^#$pr: no verifier hold: " <<<"$out"; then + echo "::error::merge-holds.mjs exited 0 without its verifier verdict." + exit 1 + fi # ORDERED SKIPS ARE CHECKED HERE. A test lane `test-`, or `lint-shell` # on its `run_shell` row, skips as a job when `select-tests`'s `run_` # row says the change gives it no work. Its diff --git a/.github/workflows/pr-verify.yml b/.github/workflows/pr-verify.yml index a5f09d6a33..0f0993dc17 100644 --- a/.github/workflows/pr-verify.yml +++ b/.github/workflows/pr-verify.yml @@ -7,8 +7,10 @@ name: pr-verify # Only steps of a run of this file on main can upload that run's artifacts, so # the pr-merge lane counts a verdict only through its run record # (plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py). -# The verdict is not a check run and stays out of ci-status -# (docs/conventions/pr-pipeline/README.md, "Verifier verdict"). +# The verdict is not a check run. ci-status reads a pass by exact name only in +# the merge queue, for a lanes App pull request, since a red ci-status on a +# pull request event would stop this workflow from running +# (docs/conventions/pr-pipeline/README.md, "Native auto-merge"). # # No pull request code runs here. The diff, the trusted PR text and the linked # issues are data files in pr-verify-data under the working directory, and the diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index d9447db86a..9dab923603 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1049,7 +1049,16 @@ opens with `Closes #`, and carries no hold: no `Hold:` line or "do not me (the gate's own rule), no `do-not-merge` or human-gated label, and no member (owner, member or collaborator) whose newest comment, review or inline comment with text says "do not merge" (the gate's veto rule; a later comment lifts it), and whose head changes no path in the lane's -`merge.diff-check.denied-paths`, read on the files the head changes at each run. It turns auto-merge off on an armed one that now fails +`merge.diff-check.denied-paths`, read on the files the head changes at each run, and whose +verifier verdict at that head pr-merge's gate would take: `babysit_verify_evidence.py evidence` +refuses nothing, so a PASS or PASS-NOTES of [pr-verify](#verifier-verdict) stands at the 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 and arm, last, for a pull request every other rule would arm or keep armed, with the gate's +saved listing, head-ref map and run cache; an event run reads no verdict, never arms, and only +disarms on a hold, so a pull request waits up to one scheduled interval to be armed. `ci-status` +turns an armed lanes App pull request's auto-merge off on each push to its head, so a new head is +armed only after the reader judges it. It turns auto-merge off on an armed one that now fails any of those, and does not turn it back on once a person has turned it off. GitHub then merges the pull request through the merge queue once `ci-status` passes. The issue lane finishing (`workflow_run`) wakes it, so a pull request is armed right after report-outcome opens it; a listed human's edit, unlabel or ready wakes it too, so @@ -1084,6 +1093,17 @@ resolve-config always denies, so a lanes App pull request cannot weaken the rule `ci-status` at once. A comment does not, so a veto posted while armed takes effect when the queue builds the group, and after a person lifts one with a later comment, `ci-status` needs a re-run. +In the merge queue the same step also requires a verifier pass at the queued pull request's live +head: an unexpired `pr-verify---pass` or `-pass-notes` artifact, with or without the +head-branch digest, looked up by exact name and uploaded by a completed, successful +`workflow_run` run of `pr-verify.yml` on main here (`verdictHold` in `merge-holds.mjs`). Whatever +GitHub does with an armed request when the head moves, the queue's check on the head it merges +holds a head pr-verify has not passed. The same trusted approval of the head lifts it, the path for a pull +request the gate sends to a person. It runs only in the queue: pr-verify runs only after a +successful `pull_request` run of `pr-require-checks`, so a red `ci-status` there would never get a +verdict, and a pull request event makes no verdict read. FAILs and the repeated-FAIL cap are +`enable-auto-merge.mjs`'s, whose reader arms nothing they refuse. + ### Dependabot pull requests No Dependabot pull request waits on a human diff --git a/plugins/source-control/skills/babysit-prs/reference/guard-contract.md b/plugins/source-control/skills/babysit-prs/reference/guard-contract.md index 8c3abd47c9..164e6146f1 100644 --- a/plugins/source-control/skills/babysit-prs/reference/guard-contract.md +++ b/plugins/source-control/skills/babysit-prs/reference/guard-contract.md @@ -34,6 +34,7 @@ This table scopes `mutation` to DOMAIN state -- GitHub, the queue state file, wo | `skills/babysit-prs/scripts/babysit_merge_prefilter.py` | -- | read-only | nothing | n/a | Pure classification of the candidate list on stdin into kept and held, printed on stdout; no write of any kind and no GitHub call. | -- | | `skills/babysit-prs/scripts/babysit_whole_review.py` | -- | mutating | a pull request's draft state | pr-merge.yml runs `recover` with the lane token, live mode only | `recover` reads each listed draft's comments and newest conversion to draft, and marks ready a draft whose newest reasons comment (``) by the lane identity records a whole-review request, when the lane identity also made that conversion within ten minutes after a record. | `whole-review.marks-ready` | | `skills/babysit-prs/scripts/babysit_merge_skip.py` | -- | read-only | nothing on GitHub | n/a | Reads GitHub (one GraphQL query per 20 candidates, the artifact listing) and writes only the local files its arguments name: the kept candidates, the skipped rows, the keys and the refusal store, plus the artifact listing `BABYSIT_VERIFY_LISTING` names. No GitHub write on any path. | -- | +| `skills/babysit-prs/scripts/babysit_verify_evidence.py` | -- | read-only | nothing on GitHub | n/a | `evidence` reads the pull request, its commits, the artifact listing, run records, compares and verdict files, and prints the gate's verifier evidence as one JSON line; it writes only the local listing, head-ref and run-cache files the BABYSIT_* variables name. No GitHub write on any path. | -- | | `skills/babysit-prs/scripts/pr_review_threads.py` | `scripts/source-control-review-threads` | read-only | nothing | n/a | The pull-request skill's readiness thread gate. It reads unresolved review threads and reports THREADS_OK / THREADS_BLOCKED / THREADS_UNPROVEN; it writes no file and performs no GitHub write. | -- | | `scripts/babysit-readiness-gate.sh` | -- | read-only | nothing | n/a | The lane's one entry point outside the skill's own scripts directory, invoked by name from SKILL.md. It counts findings and classification rows over fetched comments and reports READINESS_OK / READINESS_BLOCKED; it writes no file and performs no GitHub write. | -- | diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py index c06b57d287..da55b26986 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_verify_evidence.py @@ -88,16 +88,23 @@ steps can upload (.github/scripts/pr-merge/head-ref-map.mjs). An entry ties or unties the FAIL as the download would; a missing or malformed map is no map, and the FAIL is downloaded. + +`python3 babysit_verify_evidence.py evidence --repo R [--max-fails N] +` prints `Evidence.report()` for one pull request as one JSON line; +.github/scripts/pr-merge/enable-auto-merge.mjs arms native auto-merge only on +its null `refusal` at the head it read. """ from __future__ import annotations +import argparse import contextlib import hashlib import io import json import os import re +import sys import tempfile import time import zipfile @@ -1333,3 +1340,63 @@ def queued_hold(self, number: int) -> str | None: return None head = str(json_object(pr.get("head")).get("sha")) return self.evidence(number, head, pr).refusal + + +def main(argv: list[str] | None = None, *, now: datetime | None = None) -> int: + """`evidence `: one pull request's evidence at `head` as + one JSON line (`Evidence.report`), as pr-merge's gate judges it. + enable-auto-merge.mjs arms native auto-merge only on a null `refusal` + at the head it read. Exit 0 whenever the line is printed, a refusal + included. + + With `--base-sha`, the FAIL cap is the gate's: `merge.max-verify-fails` + of the config at that base commit and at the default branch head, the + lower one (`with_default_branch`); a config that cannot be read refuses + `verify:unreadable`. A pass judged on the run's saved listing is judged + again on a listing of its own, as the gate does before it allows.""" + parser = argparse.ArgumentParser( + prog="babysit_verify_evidence.py", allow_abbrev=False + ) + sub = parser.add_subparsers(dest="command", required=True) + cmd = sub.add_parser("evidence", allow_abbrev=False) + cmd.add_argument("--repo", required=True) + cmd.add_argument("--branch", default="main") + cmd.add_argument("--max-fails", type=int, default=merge_policy.MAX_VERIFY_FAILS) + cmd.add_argument("--base-sha", default=None) + cmd.add_argument("number", type=int) + cmd.add_argument("head") + args = parser.parse_args(argv) + if ( + args.max_fails < 1 + or not merge_policy.SHA_RE.fullmatch(args.head) + or ( + args.base_sha is not None + and not merge_policy.SHA_RE.fullmatch(args.base_sha) + ) + ): + parser.error( + "--max-fails must be 1 or more, and head and --base-sha full commit SHAs" + ) + max_fails = args.max_fails + if args.base_sha is not None: + try: + base = merge_policy.read_config_at(args.repo, args.base_sha) + if base.error: + raise merge_policy.PolicyReadError(base.error) + config = merge_policy.with_default_branch(args.repo, base) + except merge_policy.PolicyReadError as exc: + refusal = Evidence(UNREADABLE, args.head, (), 0, f"config: {exc}") + print(json.dumps(refusal.report())) + return 0 + max_fails = config.max_verify_fails + reader = Reader(args.repo, args.branch, now=now, max_fails=max_fails) + evidence = reader.evidence(args.number, args.head) + if evidence.refusal is None and reader.saved: + reader.refresh() + evidence = reader.evidence(args.number, args.head) + print(json.dumps(evidence.report())) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py b/plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py index 011b743de3..9ebbc6428f 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/guard_contract.py @@ -48,6 +48,7 @@ FINDINGS_CLI = "skills/babysit-prs/scripts/babysit_findings.py" PREFILTER_CLI = "skills/babysit-prs/scripts/babysit_merge_prefilter.py" SKIP_CLI = "skills/babysit-prs/scripts/babysit_merge_skip.py" +VERIFY_EVIDENCE_CLI = "skills/babysit-prs/scripts/babysit_verify_evidence.py" WHOLE_REVIEW_CLI = "skills/babysit-prs/scripts/babysit_whole_review.py" THREADS_CLI = "skills/babysit-prs/scripts/pr_review_threads.py" READINESS_GATE = "scripts/babysit-readiness-gate.sh" @@ -1576,6 +1577,21 @@ def _thread( ), backed_by=(), ), + EntryPoint( + path=VERIFY_EVIDENCE_CLI, + wrapper=None, + mutation=READ_ONLY, + mutates_what="nothing on GitHub", + gate="n/a", + claim=( + "`evidence` reads the pull request, its commits, the artifact " + "listing, run records, compares and verdict files, and prints the " + "gate's verifier evidence as one JSON line; it writes only the " + "local listing, head-ref and run-cache files the BABYSIT_* " + "variables name. No GitHub write on any path." + ), + backed_by=(), + ), EntryPoint( path=THREADS_CLI, wrapper=THREADS_WRAPPER, diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py index 8d06bb416c..8f0f81face 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_verify_evidence.py @@ -21,6 +21,7 @@ from __future__ import annotations +import contextlib import dataclasses import hashlib import io @@ -3737,5 +3738,165 @@ def test_the_next_sweep_downloads_only_the_new_fails(self) -> None: self.assertEqual(after[ZIP_KIND], 6) +class EvidenceCommandTest(unittest.TestCase): + """`evidence ` prints the gate's judgment for + enable-auto-merge.mjs, which arms only on a null refusal.""" + + def run_main( + self, argv: list[str], evidence: ve.Evidence + ) -> tuple[dict[str, Any], ve.Reader]: + seen: list[ve.Reader] = [] + + def judge( + reader: ve.Reader, number: int, head: str, pr: Any = None + ) -> ve.Evidence: + seen.append(reader) + self.assertEqual((number, head, pr), (PR, HEAD, None)) + return evidence + + out = io.StringIO() + with ( + mock.patch.object(ve.Reader, "evidence", judge), + contextlib.redirect_stdout(out), + ): + self.assertEqual(ve.main(argv), 0) + return json.loads(out.getvalue()), seen[0] + + def test_a_pass_prints_a_null_refusal_under_the_given_cap(self) -> None: + report, reader = self.run_main( + ["evidence", "--repo", REPO, "--max-fails", "2", str(PR), HEAD], + ve.Evidence(None, HEAD, (ve.Verdict(HEAD, "pass", 1),), 0), + ) + self.assertEqual((report["refusal"], report["head"]), (None, HEAD)) + self.assertEqual( + (reader.repo, reader.branch, reader.max_fails), (REPO, "main", 2) + ) + + def test_a_repeated_fail_prints_its_refusal(self) -> None: + report, _ = self.run_main( + ["evidence", "--repo", REPO, str(PR), HEAD], + ve.Evidence(ve.REPEATED_FAIL, HEAD, (), 0, "fails at 3 heads"), + ) + self.assertEqual(report["refusal"], ve.REPEATED_FAIL) + self.assertTrue(report["needsHuman"]) + + def test_a_short_head_or_a_zero_cap_is_refused(self) -> None: + for argv in ( + ["evidence", "--repo", REPO, str(PR), "abc"], + ["evidence", "--repo", REPO, "--max-fails", "0", str(PR), HEAD], + ): + with ( + contextlib.redirect_stderr(io.StringIO()), + self.assertRaises(SystemExit), + ): + ve.main(argv) + + +class EvidenceCommandReadsAsTheGateDoes(unittest.TestCase): + """The CLI runs once per pull request with the run's shared listing, so a + pass judged on that saved listing is judged again on its own, and the + FAIL cap is the lower of the base's and the default branch head's.""" + + def setUp(self) -> None: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.path = str(pathlib.Path(tmp.name, "verify-listing.json")) + patcher = mock.patch.dict("os.environ", {ve.LISTING_ENV: self.path}) + patcher.start() + self.addCleanup(patcher.stop) + + def cli(self, fake: FakeGitHub, *extra: str) -> dict[str, Any]: + out = io.StringIO() + with ( + mock.patch.object(policy, "pull_request", return_value=pull()), + mock.patch.object(postmerge, "gh_capture", side_effect=fake), + contextlib.redirect_stdout(out), + ): + argv = ["evidence", "--repo", REPO, *extra, str(PR), HEAD] + self.assertEqual(ve.main(argv, now=NOW), 0) + return json.loads(out.getvalue()) + + def test_a_pass_on_the_saved_listing_meets_a_fail_uploaded_since(self) -> None: + # The first pull request of the run saves a listing with a pass only. + self.assertIsNone(self.cli(passing())["refusal"]) + self.assertTrue(pathlib.Path(self.path).exists()) + # A FAIL at the head lands; the next process's saved listing lacks it. + fake = passing(artifact(named(HEAD, "fail"), 2), runs={2: run(2)}) + self.assertEqual(self.cli(fake)["refusal"], "verify:fail") + self.assertEqual(listings(fake), 1) + + def test_a_refusal_on_the_saved_listing_reads_no_listing(self) -> None: + fake = FakeGitHub([artifact(named(HEAD, "fail"), 1)], {1: run(1)}) + self.assertEqual(self.cli(fake)["refusal"], "verify:fail") + self.assertEqual(self.cli(fake)["refusal"], "verify:fail") + self.assertEqual(listings(fake), 1) + + def cap(self, base_cap: int, main_cap: int) -> int: + """The cap the reader judges with for --base-sha, the default branch + head's config carrying `main_cap`.""" + config = policy.PipelineConfig(MAIN_SHA, "C2", "pr-merge", None, None, (), None) + seen: list[int] = [] + + def judge( + reader: ve.Reader, number: int, head: str, pr: Any = None + ) -> ve.Evidence: + seen.append(reader.max_fails) + return ve.Evidence(None, head, (), 0) + + with ( + mock.patch.object( + policy, + "read_config_at", + return_value=dataclasses.replace( + config, base_sha=EARLIER, max_verify_fails=base_cap + ), + ), + mock.patch.object( + policy, + "_default_branch_config", + return_value=dataclasses.replace(config, max_verify_fails=main_cap), + ), + mock.patch.object(ve.Reader, "evidence", judge), + contextlib.redirect_stdout(io.StringIO()), + ): + ve.main( + [ + "evidence", + "--repo", + REPO, + "--max-fails", + "9", + "--base-sha", + EARLIER, + str(PR), + HEAD, + ], + now=NOW, + ) + return seen[0] + + def test_main_raising_the_cap_does_not_raise_it(self) -> None: + self.assertEqual(self.cap(3, 5), 3) + + def test_main_lowering_the_cap_lowers_it(self) -> None: + self.assertEqual(self.cap(3, 2), 2) + + def test_an_unreadable_config_refuses(self) -> None: + out = io.StringIO() + with ( + mock.patch.object( + policy, + "read_config_at", + side_effect=policy.PolicyReadError("contents 502"), + ), + contextlib.redirect_stdout(out), + ): + ve.main( + ["evidence", "--repo", REPO, "--base-sha", EARLIER, str(PR), HEAD], + now=NOW, + ) + self.assertEqual(json.loads(out.getvalue())["refusal"], ve.UNREADABLE) + + if __name__ == "__main__": unittest.main() diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_verify_rules_sync.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_verify_rules_sync.py index 02f55a518f..4af4b3ee46 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_verify_rules_sync.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_verify_rules_sync.py @@ -255,5 +255,85 @@ def jq(program: str, payload: Any) -> Any: ) +QUEUE_CHECK = RESOLVER.parents[1] / "pr-merge/merge-holds.mjs" +REQUIRE_CHECKS = RESOLVER.parents[2] / "workflows/pr-require-checks.yml" +# The fields the queue check reads differ from the gate's `_origin` only in +# its compare that main holds the run's head SHA, which the queue check skips +# (main's ruleset forbids the force push that could drop it). +HEAD_ONLY = {"head_sha"} + + +def queue_counts(runs: list[dict[str, Any]]) -> list[bool]: + script = ( + f"import {{ verdictHold }} from {json.dumps(QUEUE_CHECK.as_uri())};" + " const { runs, repo } = JSON.parse(process.argv[1]);" + " console.log(JSON.stringify(runs.map((run) => verdictHold([{ name: 'n', verdict: 'pass', runId: 1, run }], repo) === null)));" + ) + proc = subprocess.run( + [ + "node", + "--input-type=module", + "-e", + script, + json.dumps({"runs": runs, "repo": gate.REPO}), + ], + capture_output=True, + text=True, + check=True, + ) + return json.loads(proc.stdout) + + +@unittest.skipUnless(shutil.which("node"), "node not installed") +class TheQueueCheckAndTheGateAgree(unittest.TestCase): + """ci-status's merge-queue pass check (pr-require-checks.yml's lookup and + merge-holds.mjs verdictHold) counts a pass from the runs the gate counts.""" + + def test_a_pass_counts_from_the_same_runs(self) -> None: + cases = {label: gate.run(1, **overrides) for label, overrides in RUNS.items()} + counted = queue_counts(list(cases.values())) + for (label, run), js in zip(cases.items(), counted, strict=True): + with self.subTest(label): + if set(RUNS[label]) <= HEAD_ONLY: + # Only the head SHA differs: the gate's compare decides. + self.assertEqual(js, True) + else: + self.assertEqual(js, gate_counts(run, "pass")) + self.assertIn(True, counted) + self.assertIn(False, counted) + + def test_the_lookup_names_and_origin_are_the_gate_s(self) -> None: + text = REQUIRE_CHECKS.read_text(encoding="utf-8") + start = text.index(" verdicts=()\n") + body = text[ + text.index('if [ "$GITHUB_EVENT_NAME" = merge_group ]; then', start) : + ] + body = body[: body.index(" out=$(node")] + # Every pass name, plain and with the head-ref digest, by exact name. + for name in ( + '"pr-verify-$pr-$head-pass"', + '"pr-verify-$pr-$head-pass-$digest"', + '"pr-verify-$pr-$head-pass-notes"', + '"pr-verify-$pr-$head-pass-notes-$digest"', + ): + self.assertIn(name, body) + self.assertIn(f"sha256sum | cut -c1-{ve.HEAD_REF_DIGEST_HEX})", body) + # The early stop counts a pass only from the gate's verifier runs. + for clause in ( + f'.path == "{ve.WORKFLOW_PATH}"', + '.event == "workflow_run"', + '.head_branch == "main"', + ".repository.full_name == $repo", + ".head_repository.full_name == $repo", + '.status == "completed"', + '.conclusion == "success"', + ): + self.assertIn(clause, body) + self.assertIn( + f'VERIFY_PATH = "{ve.WORKFLOW_PATH}"', + QUEUE_CHECK.read_text(encoding="utf-8"), + ) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci-lanes-pr-hold.test.sh b/scripts/ci-lanes-pr-hold.test.sh index b164ce0be0..acbcb59095 100755 --- a/scripts/ci-lanes-pr-hold.test.sh +++ b/scripts/ci-lanes-pr-hold.test.sh @@ -93,7 +93,10 @@ fi # set. A files read prints each row of STUB_FILES (a JSON array of # [path, renamed-from?] arrays) as one line. A contents read of # trusted-actors.json prints the working tree's list, or answers 404 when -# STUB_NO_ACTORS is set. +# STUB_NO_ACTORS is set. An artifacts read by name prints the run ids +# STUB_ARTIFACTS (a JSON object of name to run ids) gives that name, through +# the step's own --jq; a run read prints that id's entry of STUB_RUNS, or +# answers 404. mkdir -p "$TMP_ROOT/bin" cat >"$TMP_ROOT/bin/gh" <<'EOF' #!/usr/bin/env bash @@ -103,6 +106,10 @@ discussion() { [[ "$(grep -cE '/(comments|reviews)\?' "$STUB_LOG")" -gt "${STUB_FAILS:-0}" ]] || { echo 'gh: Server Error (HTTP 502)' >&2; exit 1; } } case "$*" in +"api graphql "*) + [[ -z "${STUB_DISARM_FAILS:-}" ]] || { echo 'gh: Resource not accessible by integration' >&2; exit 1; } + echo '{"data":{}}' + ;; */contents/.github/scripts/pr-merge/merge-holds.mjs*) [[ -z "${STUB_NO_MODULE:-}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } cat "$STUB_MODULE" @@ -119,6 +126,20 @@ case "$*" in */issues/*/comments\?*) discussion; rows "${STUB_ISSUE:-}" ;; */pulls/*/reviews\?*) discussion; rows "${STUB_REVIEWS:-}" ;; */pulls/*/comments\?*) discussion; rows "${STUB_INLINE:-}" ;; +*/actions/artifacts\?name=*) + all="$*" name=${all##*actions/artifacts?name=} + name=${name%%&*} + # The step's own --jq filter, the last argument, runs over a listing that + # also holds an expired artifact of that name and an unexpired one whose + # name only starts with it, both of run 11, which neither may count. + jq -c --arg name "$name" '{artifacts: ([.[$name][]? | {name: $name, expired: false, workflow_run: {id: .}}] + + [{name: $name, expired: true, workflow_run: {id: 11}}, {name: ($name + "-i1"), expired: false, workflow_run: {id: 11}}])}' \ + <<<"${STUB_ARTIFACTS:-{\}}" | jq -r "${*: -1}" + ;; +*/actions/runs/*) + all="$*" id=${all##*actions/runs/} + jq -ce --arg id "$id" '.[$id] // error' <<<"${STUB_RUNS:-{\}}" 2>/dev/null || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + ;; */pulls/*) printf '%s\n' "$STUB_PULL" ;; *) exit 1 ;; esac @@ -131,7 +152,8 @@ BASE=89abcdef0123456789abcdef0123456789abcdef QUEUE="refs/heads/gh-readonly-queue/main/pr-7-$SHA" LANES='{"id":337595936,"login":"melodic-automation-lanes[bot]","type":"Bot"}' pull() { jq -nc --argjson user "${2:-$LANES}" --arg body "$1" --arg ref "${STUB_BASE_REF:-main}" --arg sha "${STUB_BASE_SHA:-$BASE}" \ - --argjson changed "${STUB_CHANGED:-1}" '{number: 7, user: $user, body: $body, head: {sha: "'"$SHA"'"}, base: {ref: $ref, sha: $sha}, changed_files: $changed}'; } + --argjson changed "${STUB_CHANGED:-1}" '{number: 7, node_id: "PR_7", user: $user, body: $body, head: {sha: "'"$SHA"'", ref: "work-resolve-issue/issue-1"}, base: {ref: $ref, sha: $sha}, changed_files: $changed, + auto_merge: (if env.STUB_ARMED then {merge_method: "squash"} else null end)}'; } comment() { jq -nc --arg login "$1" --arg type "${2:-User}" --arg assoc "$3" --arg at "$4" --arg body "$5" \ '{user: {login: $login, type: $type}, author_association: $assoc, created_at: $at, body: $body}'; } # approval : a review of a commit. @@ -146,7 +168,7 @@ expect() { local label="$1" want_rc="$2" want_calls="$3" out rc calls : >"$TMP_ROOT/log" out="$(PATH="$TMP_ROOT/bin:$PATH" STUB_LOG="$TMP_ROOT/log" STUB_MODULE="${STUB_MODULE:-$MODULE}" \ - GITHUB_EVENT_NAME="${EVENT:-pull_request}" GITHUB_REF="${REF:-refs/pull/7/merge}" PR_NUMBER="${PR_NUMBER-7}" \ + GITHUB_EVENT_NAME="${EVENT:-pull_request}" EVENT_ACTION="${ACTION-synchronize}" GITHUB_REF="${REF:-refs/pull/7/merge}" PR_NUMBER="${PR_NUMBER-7}" \ GITHUB_REPOSITORY=o/r RUNNER_TEMP="$TMP_ROOT" GROUP_BASE_SHA="${GROUP_BASE_SHA-}" bash -e "$TMP_ROOT/body.sh" 2>&1)" && rc=0 || rc=$? calls=$(grep -cE '/(comments|reviews)\?' "$TMP_ROOT/log") if [[ "$rc" -ne "$want_rc" ]]; then @@ -260,7 +282,133 @@ export STUB_NO_CONFIG=1 expect "an unreadable config fails closed" 1 0 "::error::could not read docs/conventions/pr-pipeline.yaml at $BASE" unset STUB_NO_CONFIG +# verifier runs: run [path] [event] [branch] [status] [conclusion] +run() { jq -nc --argjson id "$1" --arg path "${2:-.github/workflows/pr-verify.yml}" --arg event "${3:-workflow_run}" \ + --arg branch "${4:-main}" --arg status "${5:-completed}" --arg conclusion "${6:-success}" \ + '{id: $id, path: $path, event: $event, head_branch: $branch, status: $status, conclusion: $conclusion, + repository: {full_name: "o/r"}, head_repository: {full_name: "o/r"}}'; } +DIGEST="$(printf '%s' work-resolve-issue/issue-1 | sha256sum | cut -c1-16)" +OLD=fedcba9876543210fedcba9876543210fedcba98 +# lookups: the artifact-by-name reads the last run made. +lookups() { grep -c 'actions/artifacts?name=' "$TMP_ROOT/log"; } +STUB_RUNS="$(jq -sc 'map({(.id | tostring): .}) | add' <<<"$(run 11) +$(run 12 .github/workflows/pr-verify.yml workflow_run main in_progress '') +$(run 13 .github/workflows/other.yml) +$(run 14 .github/workflows/pr-verify.yml pull_request) +$(run 15 .github/workflows/pr-verify.yml workflow_run work-resolve-issue/issue-1) +$(run 16 .github/workflows/pr-verify.yml workflow_run main completed failure)")" +# Run 17's read answers with run 11's record: another run than its artifact names. +STUB_RUNS="$(jq -c '. + {"17": .["11"]}' <<<"$STUB_RUNS")" +export STUB_RUNS + +STUB_PULL="$(pull 'Closes #1')" +expect "a pull request event reads no verdict" 0 3 "no hold path" +if [[ "$(lookups)" -eq 0 ]] && ! grep -q 'actions/runs/' "$TMP_ROOT/log"; then + ok "a pull request event makes no verdict read, so pr-verify can follow a green ci-status" +else + fail "a pull request event read a verdict: '$(sort -u "$TMP_ROOT/log")'" +fi + +# disarms: the auto-merge mutations the last run sent. +disarms() { grep -c '^api graphql .*disablePullRequestAutoMerge' "$TMP_ROOT/log"; } +# no_disarm