diff --git a/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md new file mode 100644 index 0000000000..c2f30a3888 --- /dev/null +++ b/.changes/pr-pipeline/feat-enable-auto-merge-activity-4e7a2c91.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- The pr-merge lane's `enable-auto-merge` activity (effect `merge`) turns on GitHub's native auto-merge, squash at the head it read, for each open lanes App pull request into main that opens with `Closes #` and carries no hold (a `Hold:` line or "do not merge" in its body, `do-not-merge`, the human-gated label, a member's newest comment saying "do not merge", a head that now changes a denied path), and turns it off on an armed one that now carries a hold. Auto-merge a person turned off stays off. `pr-merge.yml` runs it in its own job, with its own lanes App token, when the issue lane finishes (a new `workflow_run` trigger the merge job ignores), on a listed human's edit, unlabel or ready, and on the schedule. While `merge.report-only` is true it writes nothing ([README.md, Native auto-merge](https://github.com/melodic-software/claude-code-plugins/blob/main/docs/conventions/pr-pipeline/README.md#native-auto-merge)). `ci-status` fails a lanes App pull request on a body hold, a member's veto comment or a changed or renamed-from denied path (lifted by a trusted person's approval of the head), on each pull request event and in the merge queue, so a hold added after arming stops the merge. diff --git a/.github/actions/resolve-config/merge-authority.test.mjs b/.github/actions/resolve-config/merge-authority.test.mjs index c6c8c4b127..7b0c375e56 100644 --- a/.github/actions/resolve-config/merge-authority.test.mjs +++ b/.github/actions/resolve-config/merge-authority.test.mjs @@ -5,6 +5,7 @@ import { test } from "node:test"; import { parse, stringify } from "yaml"; import { Rejection, resolve } from "./resolve.mjs"; +import { FORCED_DENIED_PATHS } from "../../scripts/pr-merge/merge-holds.mjs"; const read = (relative) => readFileSync(new URL(relative, import.meta.url), "utf8"); @@ -213,6 +214,14 @@ test("resolveMerge output for valid.yaml is unchanged", () => { assert.deepEqual(run(read("./fixtures/valid.yaml")).merge, VALID_MERGE); }); +// ci-status adds this floor to the base config's denied paths itself. +test("merge-holds.mjs's FORCED_DENIED_PATHS are the denied paths resolveMerge forces", () => { + assert.deepEqual( + [...run(read("./fixtures/valid.yaml")).merge["diff-check"]["denied-paths"]].sort(), + [...FORCED_DENIED_PATHS].sort(), + ); +}); + test("resolveMerge does not carry authority or agent-identity into the output", () => { assert.deepEqual( run(withMerge({ authority: "local-babysit", "agent-identity": IDENTITY })) diff --git a/.github/scripts/pr-merge/enable-auto-merge.mjs b/.github/scripts/pr-merge/enable-auto-merge.mjs new file mode 100755 index 0000000000..c20fbab94c --- /dev/null +++ b/.github/scripts/pr-merge/enable-auto-merge.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +// enable-auto-merge.mjs: turns on GitHub's native auto-merge for the lanes +// App's open pull requests into main, and turns it off on one that now carries +// a hold. No model. pr-merge.yml's enable-auto-merge job runs it when the issue +// lane finishes, when a listed human edits, unlabels or readies a pull +// request, and on the schedule, which arms any the other events missed. +// +// A pull request is armed only when all of these hold: +// - The lanes App opened it (merge.agent-identity), into main, from a branch +// of this repository, and it is not a draft. +// - Its body opens with `Closes #`: a `Refs:` pull request is merged +// by a person (report-outcome.mjs). +// - It carries no hold: no `do-not-merge` and no human-gated label (the +// tracker's role label, `needs-human` by default), and no body hold +// (merge-holds.mjs). +// - No member vetoes it (merge-holds.mjs). ci-status fails on both holds too, +// so one added after arming stops the merge queue rather than waiting for +// this job's next run. +// - Its head changes no hold path: the merge lane's denied paths +// (report-outcome.readHoldPaths), read against the files, renamed-from paths +// included, that the head changes now, since a push after report-outcome +// wrote its Hold: line can add one. The rule is merge-holds.pathHold, which +// ci-status also runs on every lanes App pull request event and merge +// 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. +// 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 +// each decision and writes nothing. +// +// Environment: +// GH_TOKEN the lane App token (contents and pull-requests write) +// GITHUB_REPOSITORY owner/repo +// KILL_SWITCH `proceed` from the kill-switch step; anything else stops +// REPORT_ONLY `false` writes; anything else reports only +// LANES_BOT_LOGIN merge.agent-identity, an App bot login +// 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 +// GITHUB_API_URL +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { createGitHub } from "../../actions/check-trusted-trigger/check-trusted-trigger.mjs"; +import { humanGatedLabelFrom } from "../pr-unblock/address-feedback.mjs"; +import { readHoldPaths } from "../work-resolve-issue/report-outcome.mjs"; +import { BODY_HOLD, pathHold, vetoers } from "./merge-holds.mjs"; + +export { BODY_HOLD, HUMAN_VETO } from "./merge-holds.mjs"; + +const REPO = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/; +const SHA = /^[0-9a-f]{40}$/; +const BOT_LOGIN = /^([a-z0-9][a-z0-9-]*)\[bot\]$/; +// babysit_merge_policy.MERGE_HOLD_LABELS, plus the human-gated label. +const HOLD_LABELS = Object.freeze(["do-not-merge"]); +const CLOSES = /^Closes #[1-9][0-9]*\r?$/; +// 100 pull requests a page; more than this many open is a listing the run +// stops on rather than one it reads only part of. +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; + +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 + author { __typename login } + labels(first: 100) { totalCount nodes { name } } + autoMergeRequest { enabledAt } + } + } + } +}`; +// One candidate's discussion. The newest 100 of each, and more than that is +// a discussion the run does not arm on rather than one it reads only part of. +export const DETAIL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } + reviews(last: 100) { totalCount nodes { author { __typename login } body submittedAt authorAssociation } } + reviewThreads(last: 100) { + totalCount + nodes { comments(last: 100) { totalCount nodes { author { __typename login } body createdAt authorAssociation } } } + } + timelineItems(itemTypes: [AUTO_MERGE_DISABLED_EVENT], last: 1) { nodes { ... on AutoMergeDisabledEvent { actor { __typename login } } } } + } + } +}`; +export const ARM_MUTATION = `mutation($id: ID!, $head: GitObjectID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: SQUASH, expectedHeadOid: $head }) { + pullRequest { autoMergeRequest { enabledAt } } + } +}`; +export const DISARM_MUTATION = `mutation($id: ID!) { + disablePullRequestAutoMerge(input: { pullRequestId: $id }) { clientMutationId } +}`; + +const escapeCommand = (text) => String(text).replaceAll(/[\r\n%]/g, " "); + +// Why `pull` must not be armed, or null when it may be. +export function holdReason(pull, humanGated) { + const labels = pull.labels?.nodes ?? []; + if (!(pull.labels?.totalCount <= labels.length)) { + return "its labels could not all be read"; + } + const names = new Set(labels.map((label) => String(label?.name).toLowerCase())); + const label = [...HOLD_LABELS, humanGated].find((name) => names.has(name.toLowerCase())); + if (label !== undefined) { + return `it carries ${label}`; + } + const body = String(pull.body ?? ""); + if (BODY_HOLD.test(body)) { + return "its body holds it"; + } + if (!CLOSES.test(body.split("\n")[0])) { + return "its body does not open with Closes #"; + } + return null; +} + +const whole = (connection) => Array.isArray(connection?.nodes) && connection.totalCount <= connection.nodes.length; + +// Why the discussion `detail` (DETAIL_QUERY's pullRequest) holds it, or null. +export function discussionHold(detail) { + const threads = detail?.reviewThreads; + if (![detail?.comments, detail?.reviews, threads].every(whole) || !threads.nodes.every((thread) => whole(thread?.comments))) { + return "its comments could not all be read"; + } + const items = [...detail.comments.nodes, ...detail.reviews.nodes, ...threads.nodes.flatMap((thread) => thread.comments.nodes)].map((item) => ({ + login: item?.author?.login, + isUser: item?.author?.__typename === "User", + association: item?.authorAssociation, + at: String(item?.submittedAt ?? item?.createdAt ?? ""), + body: String(item?.body ?? ""), + })); + const vetoes = vetoers(items); + return vetoes.length ? `${vetoes.join(", ")} asked not to merge it` : null; +} + +async function changedFiles(github, slug, number) { + const paths = []; + for (let page = 1; page <= MAX_FILE_PAGES; page += 1) { + const rows = await github("GET", `/repos/${slug}/pulls/${number}/files?per_page=100&page=${page}`); + if (!Array.isArray(rows)) { + return null; + } + for (const row of rows) { + paths.push(String(row?.filename), ...(row?.previous_filename ? [String(row.previous_filename)] : [])); + } + if (rows.length < 100) { + return paths; + } + } + return null; +} + +// 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`). +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" }; + } + const armed = pull.autoMergeRequest != null; + const held = + pull.isDraft !== false + ? "it is a draft" + : pull.isCrossRepository !== false + ? "it is from another repository" + : !SHA.test(String(pull.headRefOid)) + ? "its head is not a commit SHA" + : holdReason(pull, humanGated); + if (held !== null) { + return { action: armed ? "disarm" : "none", reason: held }; + } + if (detail === undefined) { + return { action: "read", reason: "its discussion is not read yet" }; + } + if (detail === null) { + return { action: "none", reason: "its discussion did not read" }; + } + const vetoed = pathHold(detail.files, holdPaths) ?? discussionHold(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") { + return { action: "none", reason: "a person turned its auto-merge off" }; + } + return { action: "arm", reason: "no hold" }; +} + +async function listPulls(github, owner, name) { + const pulls = []; + let after = null; + for (let page = 0; page < MAX_PAGES; page += 1) { + const data = (await github("POST", "/graphql", { query: PULLS_QUERY, variables: { owner, name, after } }))?.data; + const connection = data?.repository?.pullRequests; + if (!Array.isArray(connection?.nodes)) { + throw new Error("the open pull request listing did not read"); + } + pulls.push(...connection.nodes); + if (!connection.pageInfo?.hasNextPage) { + return pulls; + } + after = connection.pageInfo.endCursor; + } + throw new Error(`more than ${MAX_PAGES * 100} open pull requests into main`); +} + +export async function main({ env = process.env, github } = {}) { + try { + const repo = REPO.exec(env.GITHUB_REPOSITORY ?? ""); + if (!repo) { + throw new Error("GITHUB_REPOSITORY is not owner/repo"); + } + if (env.KILL_SWITCH !== "proceed") { + console.log("enable-auto-merge: the lanes kill switch stops this run; nothing read or armed"); + return 0; + } + const slug = BOT_LOGIN.exec(env.LANES_BOT_LOGIN ?? "")?.[1]; + if (slug === undefined) { + throw new Error("LANES_BOT_LOGIN is not an App bot login"); + } + const write = env.REPORT_ONLY === "false"; + 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 }); + const pulls = await listPulls(github, repo[1], repo[2]); + let failed = 0; + for (const pull of pulls) { + let { action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }); + 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; + if (detail !== null) { + detail = { ...detail, files: await changedFiles(github, `${repo[1]}/${repo[2]}`, pull.number) }; + } + } catch (error) { + console.log(`enable-auto-merge: #${pull.number} discussion did not read: ${escapeCommand(error?.message ?? error)}`); + } + ({ action, reason } = decide(pull, { bot: slug, humanGated, holdPaths }, detail)); + } + if (action === "none") { + if (reason !== "not the lanes App's") { + console.log(`enable-auto-merge: #${pull.number} left: ${reason}`); + } + continue; + } + if (!write) { + console.log(`enable-auto-merge: #${pull.number} would ${action} (report-only): ${reason}`); + continue; + } + try { + if (action === "arm") { + await github("POST", "/graphql", { query: ARM_MUTATION, variables: { id: pull.id, head: pull.headRefOid } }); + console.log(`enable-auto-merge: #${pull.number} armed at ${pull.headRefOid}`); + } else { + await github("POST", "/graphql", { query: DISARM_MUTATION, variables: { id: pull.id } }); + console.log(`enable-auto-merge: #${pull.number} disarmed: ${reason}`); + } + } catch (error) { + failed += 1; + console.log(`::error title=enable-auto-merge::#${pull.number} ${action}: ${escapeCommand(error?.message ?? error)}`); + } + } + return failed === 0 ? 0 : 1; + } catch (error) { + console.log(`::error title=enable-auto-merge::${escapeCommand(error?.message ?? error)}`); + return 1; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exitCode = await main(); +} diff --git a/.github/scripts/pr-merge/enable-auto-merge.test.mjs b/.github/scripts/pr-merge/enable-auto-merge.test.mjs new file mode 100644 index 0000000000..9dba8db3ad --- /dev/null +++ b/.github/scripts/pr-merge/enable-auto-merge.test.mjs @@ -0,0 +1,371 @@ +// Expected values come from the native-merge plan's arm-merge item and the +// owner answers on it: a lanes App pull request is armed for native auto-merge (squash, at the head +// read) with no session, and is never armed, or is disarmed, while it carries +// a `Hold:` line, do-not-merge or the human-gated label. A Refs pull request +// is merged by a person (report-outcome.mjs), so it is never armed. The body +// hold is the gate's own rule, babysit_merge_policy.BODY_MERGE_HOLD_RE, so the +// parity test reads its verdicts from Python. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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 { 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 = { + GITHUB_REPOSITORY: REPO, + KILL_SWITCH: "proceed", + REPORT_ONLY: "false", + LANES_BOT_LOGIN: "melodic-automation-lanes[bot]", + TRACKER_CONFIG: "/nonexistent/.work-item-tracker.json", + RESOLVED_CONFIG: fileURLToPath(new URL("./fixtures/enable-auto-merge-resolved.json", import.meta.url)), +}; + +const pull = (number, overrides = {}) => ({ + id: `PR_${number}`, + number, + isDraft: false, + isCrossRepository: false, + headRefOid: HEAD, + body: `Closes #${number + 1000}\n\n## Summary\n\nDone.`, + author: LANES, + labels: { totalCount: 0, nodes: [] }, + autoMergeRequest: null, + ...overrides, +}); +// One pull request's comments, reviews, inline comments and newest +// auto-merge-disabled event, as DETAIL_QUERY returns them. +const page = (nodes = []) => ({ totalCount: nodes.length, nodes }); +function detail({ comments = [], reviews = [], inline = [], disabledBy = null } = {}) { + return { + comments: page(comments), + reviews: page(reviews), + reviewThreads: page(inline.length ? [{ comments: page(inline) }] : []), + timelineItems: { nodes: disabledBy ? [{ actor: disabledBy }] : [] }, + }; +} +const said = (login, body, at, association = "OWNER", typename = "User") => ({ + author: { __typename: typename, login }, + body, + createdAt: at, + authorAssociation: association, +}); +const labels = (...names) => ({ totalCount: names.length, nodes: names.map((name) => ({ name })) }); + +function world(pulls, { failArm = false } = {}) { + const calls = []; + const github = async (method, path, body) => { + calls.push({ method, path, body }); + const listing = /^\/repos\/melodic-software\/claude-code-plugins\/pulls\/([0-9]+)\/files\?per_page=100&page=([0-9]+)$/.exec(path); + if (method === "GET" && listing) { + const files = pulls.find((candidate) => candidate.number === Number(listing[1]))?.files ?? [{ filename: "plugins/work-items/README.md" }]; + return files.slice((Number(listing[2]) - 1) * 100, Number(listing[2]) * 100); + } + assert.equal(`${method} ${path}`, "POST /graphql"); + if (body.query === PULLS_QUERY) { + assert.deepEqual(body.variables, { owner: "melodic-software", name: "claude-code-plugins", after: null }); + return { data: { repository: { pullRequests: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: pulls } } } }; + } + if (body.query === DETAIL_QUERY) { + const found = pulls.find((candidate) => candidate.number === body.variables.number); + return { data: { repository: { pullRequest: detail(found?.discussion) } } }; + } + if (body.query === ARM_MUTATION && failArm) { + throw new Error("POST /graphql (GraphQL errors) failed with status 200"); + } + return { data: {} }; + }; + 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 }; +} + +async function run(pulls, env = {}, options = {}) { + const { github, writes } = 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 }; + } finally { + console.log = log; + } +} + +test("a lanes App pull request with no hold is armed, squash, at the head it read", async () => { + const { code, writes } = await run([pull(7)]); + assert.equal(code, 0); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_7", head: HEAD }]); + assert.match(ARM_MUTATION, /mergeMethod: SQUASH, expectedHeadOid: \$head/); +}); + +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" }), + pull(2, { body: "Closes #5\r\n hold: wait for the canary" }), + pull(3, { labels: labels("do-not-merge") }), + pull(4, { labels: labels("Needs-Human") }), + pull(5, { body: "Closes #5\n\nPlease do not merge yet." }), + ]); + assert.equal(code, 0); + assert.deepEqual(writes, []); +}); + +test("the human-gated label comes from the tracker config", async () => { + const config = fileURLToPath(new URL("./fixtures/enable-auto-merge-tracker.json", import.meta.url)); + const { writes } = await run([pull(1, { labels: labels("human-gated") }), pull(2, { labels: labels("needs-human") })], { TRACKER_CONFIG: config }); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_2", head: HEAD }]); +}); + +test("an armed pull request that now holds is disarmed", async () => { + const armed = { enabledAt: "2026-10-23T10:00:00Z" }; + const { code, writes } = await run([ + pull(1, { autoMergeRequest: armed, body: "Closes #5\n\nHold: a human merges this" }), + pull(2, { autoMergeRequest: armed, labels: labels("needs-human") }), + pull(3, { autoMergeRequest: armed }), + ]); + assert.equal(code, 0); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_1" }, + { mutation: "disarm", id: "PR_2" }, + ]); +}); + +test("only the lanes App's ready Closes pull requests from this repository are armed", async () => { + const { writes } = await run([ + pull(1, { author: { __typename: "User", login: "kyle-sexton" } }), + pull(2, { author: { __typename: "Bot", login: "dependabot" } }), + pull(3, { author: { __typename: "User", login: "melodic-automation-lanes" } }), + pull(4, { isDraft: true }), + pull(5, { isCrossRepository: true }), + pull(6, { body: "Refs: #12\n\n## Summary" }), + pull(7, { body: "## Summary\n\nCloses #12" }), + pull(8, { headRefOid: "abc" }), + pull(9, { labels: { totalCount: 101, nodes: [] } }), + ]); + assert.deepEqual(writes, []); +}); + +test("report-only arms and disarms nothing", async () => { + const { code, writes, lines } = await run([pull(1), pull(2, { autoMergeRequest: {}, labels: labels("do-not-merge") })], { REPORT_ONLY: "true" }); + assert.equal(code, 0); + assert.deepEqual(writes, []); + assert.ok(lines.some((line) => /#1 would arm \(report-only\)/.test(line))); + assert.ok(lines.some((line) => /#2 would disarm \(report-only\)/.test(line))); +}); + +test("the kill switch stops it before any read", async () => { + const { code, writes } = await run([pull(1)], { KILL_SWITCH: "" }); + assert.equal(code, 0); + assert.deepEqual(writes, []); +}); + +test("a failed arm fails the run and still reaches the next pull request", async () => { + const { code, writes, lines } = await run([pull(1), pull(2, { autoMergeRequest: {}, labels: labels("do-not-merge") })], {}, { failArm: true }); + assert.equal(code, 1); + assert.deepEqual(writes, [ + { mutation: "arm", id: "PR_1", head: HEAD }, + { mutation: "disarm", id: "PR_2" }, + ]); + assert.ok(lines.some((line) => line.startsWith("::error title=enable-auto-merge::#1 arm"))); +}); + +test("a bad bot login or repository fails red", async () => { + assert.equal((await run([pull(1)], { LANES_BOT_LOGIN: "kyle-sexton" })).code, 1); + assert.equal((await run([pull(1)], { GITHUB_REPOSITORY: "nope" })).code, 1); +}); + +test("decide leaves a pull request with no labels read alone", () => { + assert.deepEqual(decide(pull(1, { labels: null }), { bot: LANES.login, humanGated: "needs-human" }), { + action: "none", + reason: "its labels could not all be read", + }); +}); + +test("the body hold agrees with the gate's BODY_MERGE_HOLD_RE", () => { + const bodies = [ + "Hold: a human merges this", + "Closes #1\n\nHold: x", + "Closes #1\r\n HOLD: x", + "Closes #1\rhold: x", + "Closes #1\n\nthe hold: is not at the line start", + "Closes #1\n\nDon't merge", + "Closes #1\n\ndon’t merge this", + "Closes #1\n\nDo NOT\nmerge", + "Closes #1\n\ndonut merge", + "Closes #1\n\nwhat to do: merge it", + "Closes #1\n\nnothing here", + "", + ]; + const scripts = fileURLToPath(new URL("../../../plugins/source-control/skills/babysit-prs/scripts", import.meta.url)); + const python = spawnSync( + "python3", + ["-c", "import json,sys\nfrom babysit_merge_policy import BODY_MERGE_HOLD_RE as R\nprint(json.dumps([bool(R.search(b)) for b in json.load(sys.stdin)]))"], + { cwd: scripts, input: JSON.stringify(bodies), encoding: "utf8" }, + ); + assert.equal(python.status, 0, python.stderr); + const expected = JSON.parse(python.stdout); + assert.deepEqual( + bodies.map((body) => BODY_HOLD.test(body)), + expected, + ); + assert.ok(expected.includes(true) && expected.includes(false)); +}); + +// The body the issue lane opens its pull request with (report-outcome.mjs +// decide), so a change to either side that stops a clean lane pull request +// from arming, or arms a held one, fails here. +test("report-outcome's own pull request body arms, and its hold and Refs bodies do not", () => { + const plan = (overrides) => + decideOutcome({ + outcome: { exit: "done", reason: "", title: "docs(work-items): fix a typo", summary: "Fixed it." }, + issueNumber: 77, + issueLabels: ["agent-ready"], + branch: "work-resolve-issue/issue-77-123-1", + changedFiles: ["plugins/work-items/README.md"], + holdPaths: [".github/**"], + checks: { tip: HEAD, results: [] }, + ...overrides, + }); + const verdict = (overrides) => { + 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: [] }); + }; + assert.deepEqual(verdict({}), { action: "arm", reason: "no hold" }); + assert.equal(verdict({ humanMerge: true }).action, "none"); + assert.equal(verdict({ changedFiles: [".github/workflows/x.yml"] }).action, "none"); + assert.equal(verdict({ outcome: { exit: "done", reason: "", title: "t", summary: "s", link: "refs" } }).action, "none"); +}); + +// The gate's human veto (babysit_merge.human_veto_logins): each member's +// newest comment, review or inline comment with text decides, so a later +// comment lifts the hold; a non-member's, a bot's or an empty one counts for +// nothing. Native auto-merge skips that gate, so the arm rule applies it. +test("a member's newest comment saying not to merge holds, and a later one lifts it", async () => { + const veto = said("kyle-sexton", "Please don't merge this yet.", "2026-10-23T10:00:00Z"); + const { code, writes } = await run([ + pull(1, { discussion: { comments: [veto] } }), + pull(2, { discussion: { comments: [veto, said("kyle-sexton", "Go ahead.", "2026-10-23T11:00:00Z")] } }), + pull(3, { discussion: { comments: [said("someone", "do not merge", "2026-10-23T10:00:00Z", "CONTRIBUTOR")] } }), + pull(4, { discussion: { comments: [said("codex", "do not merge", "2026-10-23T10:00:00Z", "NONE", "Bot")] } }), + pull(5, { discussion: { reviews: [{ ...veto, createdAt: undefined, submittedAt: veto.createdAt }] } }), + pull(6, { discussion: { inline: [said("kyle-sexton", "do-not merge until the canary", "2026-10-23T10:00:00Z", "MEMBER")] } }), + pull(7, { discussion: { comments: [veto, said("kyle-sexton", "", "2026-10-23T11:00:00Z")] } }), + ]); + assert.equal(code, 0); + assert.deepEqual( + writes.map(({ id }) => id), + ["PR_2", "PR_3", "PR_4"], + ); +}); + +test("an armed pull request a member now vetoes is disarmed", async () => { + const { writes } = await run([ + pull(1, { autoMergeRequest: {}, discussion: { comments: [said("kyle-sexton", "do not merge", "2026-10-23T10:00:00Z")] } }), + ]); + assert.deepEqual(writes, [{ mutation: "disarm", id: "PR_1" }]); +}); + +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"); + const held = (pr) => decide(pull(1), options, { ...detail(), files: [], ...pr }); + assert.equal(held({ comments: cut }).action, "none"); + assert.equal(held({ reviews: cut }).action, "none"); + assert.equal(held({ reviewThreads: cut }).action, "none"); + assert.equal(held({ reviewThreads: page([{ comments: cut }]) }).action, "none"); + // A read that failed leaves it as it is, armed or not, until the next run. + assert.equal(decide(pull(1), options, null).action, "none"); + assert.equal(decide(pull(1, { autoMergeRequest: {} }), options, null).action, "none"); +}); + +test("auto-merge a person turned off stays off; one the lanes App turned off is armed again", async () => { + const { writes } = await run([ + pull(1, { discussion: { disabledBy: { __typename: "User", login: "kyle-sexton" } } }), + pull(2, { discussion: { disabledBy: { __typename: "Bot", login: "melodic-automation-lanes" } } }), + ]); + assert.deepEqual(writes, [{ mutation: "arm", id: "PR_2", head: HEAD }]); +}); + +test("the comment veto agrees with the gate's HUMAN_MERGE_VETO_RE", () => { + const bodies = ["do not merge", "Don't merge", "don’t merge", "dont-merge", "do-not merge", "Do NOT\nmerge", "donut merge", "merge it", "doesn't merge", ""]; + const scripts = fileURLToPath(new URL("../../../plugins/source-control/skills/babysit-prs/scripts", import.meta.url)); + const python = spawnSync( + "python3", + ["-c", "import json,sys\nfrom babysit_merge import HUMAN_MERGE_VETO_RE as R\nprint(json.dumps([bool(R.search(b)) for b in json.load(sys.stdin)]))"], + { cwd: scripts, input: JSON.stringify(bodies), encoding: "utf8" }, + ); + assert.equal(python.status, 0, python.stderr); + const expected = JSON.parse(python.stdout); + assert.deepEqual( + bodies.map((body) => HUMAN_VETO.test(body)), + expected, + ); + assert.ok(expected.includes(true) && expected.includes(false)); +}); + +// The merge lane's denied paths (report-outcome.readHoldPaths), on the head's +// files now: report-outcome's Hold: line covers only the files at opening, and +// a later push can add a held path to a pull request that is already armed. +test("a pull request whose head now changes a hold path is not armed, or is disarmed", async () => { + const { writes } = await run([ + pull(1, { files: [{ filename: ".github/workflows/x.yml" }] }), + pull(2, { autoMergeRequest: {}, files: [{ filename: "plugins/a/README.md" }, { filename: "docs/a.md", previous_filename: ".github/a.md" }] }), + pull(3, { files: Array.from({ length: 301 }, (_, index) => ({ filename: `docs/${index}.md` })) }), + pull(4, { files: [{ filename: "docs/held.md" }] }), + pull(5), + ]); + assert.deepEqual(writes, [ + { mutation: "disarm", id: "PR_2" }, + { mutation: "arm", id: "PR_5", head: HEAD }, + ]); +}); + +// ci-status runs merge-holds.mjs on the live files with the base config's +// denied paths, by the same pathHold that decides arming above. +test("merge-holds.mjs holds a changed or renamed-from hold path from the command line", () => { + const dir = mkdtempSync(join(tmpdir(), "merge-holds-")); + const head = "0123456789abcdef0123456789abcdef01234567"; + const cli = (files, denied, reviews = []) => { + writeFileSync(join(dir, "body"), "Closes #1"); + writeFileSync(join(dir, "discussion"), reviews.map((row) => JSON.stringify(row)).join("\n")); + writeFileSync(join(dir, "actors"), JSON.stringify({ actors: [{ kind: "human", login: "ana", id: 5 }] })); + writeFileSync(join(dir, "files"), files.map((row) => JSON.stringify(row)).join("\n")); + writeFileSync(join(dir, "denied"), JSON.stringify(denied)); + const args = [...["7", "body", "discussion", "files", "denied", "actors"].map((name, index) => (index ? join(dir, name) : name)), head]; + return spawnSync(process.execPath, [fileURLToPath(new URL("./merge-holds.mjs", import.meta.url)), ...args], { encoding: "utf8" }); + }; + assert.equal(cli([["docs/a.md"]], ["docs/held.md"]).status, 0); + assert.match(cli([[".github/workflows/.x.yml"]], []).stdout, /it changes \.github\/workflows\/\.x\.yml, a hold path/); + assert.match(cli([["plugins/a/.hidden"]], ["plugins/**"]).stdout, /it changes plugins\/a\/\.hidden, a hold path/); + assert.equal(cli([["plugins/a/x.md"]], ["plugins/*.md"]).status, 0); + const renamed = cli([["docs/a.md", ".github/a.md"]], []); + assert.equal(renamed.status, 1); + assert.match(renamed.stdout, /it changes \.github\/a\.md, a hold path/); + assert.match(cli([["docs/held.md"]], ["docs/held.md"]).stdout, /it changes docs\/held\.md, a hold path/); + assert.match(cli([["docs/a.md"]], "docs/held.md").stdout, /the hold paths could not be read/); + const approve = (id, commit) => ({ user: { login: `u${id}`, id, type: "User" }, state: "APPROVED", commit_id: commit, submitted_at: "2026-10-23T11:00:00Z" }); + assert.equal(cli([["docs/held.md"]], ["docs/held.md"], [approve(5, head)]).status, 0); + assert.equal(cli([["docs/held.md"]], ["docs/held.md"], [approve(6, head)]).status, 1); + assert.equal(cli([["docs/held.md"]], ["docs/held.md"], [approve(5, "f".repeat(40))]).status, 1); + assert.equal(cli([["docs/a.md"]], "docs/held.md", [approve(5, head)]).status, 1); +}); + +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, []); +}); diff --git a/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json b/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json new file mode 100644 index 0000000000..47a76fc4cd --- /dev/null +++ b/.github/scripts/pr-merge/fixtures/enable-auto-merge-resolved.json @@ -0,0 +1,7 @@ +{ + "merge": { + "diff-check": { + "denied-paths": [".github/**", "docs/held.md"] + } + } +} diff --git a/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json b/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json new file mode 100644 index 0000000000..b778c55da0 --- /dev/null +++ b/.github/scripts/pr-merge/fixtures/enable-auto-merge-tracker.json @@ -0,0 +1,7 @@ +{ + "config": { + "role_labels": { + "human-gated": "human-gated" + } + } +} diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs new file mode 100755 index 0000000000..bc8dd278fc --- /dev/null +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +// merge-holds.mjs: the merge holds a pull request's own text carries, by the +// rules pr-merge's gate reads them. enable-auto-merge.mjs arms nothing they +// hold, and ci-status runs this file alone, fetched from the base commit, on +// every lanes App pull request, so it imports no other file of the repository. +// +// - A body hold (babysit_merge_policy.BODY_MERGE_HOLD_RE): a line opening with +// `Hold:`, or "do not merge" anywhere. Removing that text lifts it. +// - A member veto (babysit_merge.human_veto_logins): each OWNER, MEMBER or +// COLLABORATOR person's newest comment, review or inline comment with text +// decides, and holds when it says "do not merge" +// (babysit_merge.HUMAN_MERGE_VETO_RE). A later one by the same person +// lifts it; an item with no text lifts nothing. +// - A hold path: a changed file, or a renamed file's old path, matching the +// merge lane's denied paths, which resolve-config resolves as the config's +// 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. +// +// Command line: merge-holds.mjs +// . 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 +// merge.diff-check.denied-paths as JSON; the trusted-actors file +// trusted-actors.json. Each hold prints as an ::error:: and exits 1; no hold +// exits 0. +import { readFileSync } from "node:fs"; +import { posix } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +// enable-auto-merge.test.mjs checks both agree with the gate's Python on every +// text it tries. +export const BODY_HOLD = /\bdo(?:n['’]?t| not)\s+merge\b|(?:^|\r)[^\S\r\n]*hold:/im; +export const HUMAN_VETO = /\bdo(?:n['’]?t| not|-not)[\s-]*merge\b/i; +// babysit_merge_policy.BLOCKER_ASSOCIATIONS. +const MEMBERS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); +// The denied paths resolve-config adds to every config's (resolveMerge); +// merge-authority.test.mjs checks the two agree. +export const FORCED_DENIED_PATHS = Object.freeze([ + "docs/conventions/pr-pipeline.yaml", + ".github/**", + ".github/standards/trusted-actors/trusted-actors.json", +]); +// Every hold path check adds these: the forced paths, the rest of the merge +// gate's HIGH floor (babysit_risk.HIGH_FLOOR), and every plugin manifest, a +// path-wide stand-in for the gate's executable-key manifest check. +export const HOLD_PATH_FLOOR = Object.freeze([ + ...FORCED_DENIED_PATHS, + "**/CODEOWNERS", + "**/.gitmodules", + "**/.claude-plugin/plugin.json", +]); + +// The members whose newest item with text vetoes the merge, sorted. Each item +// is { login, isUser, association, at, body }, `at` an ISO 8601 UTC time. +export function vetoers(items) { + const newest = new Map(); + for (const { login, isUser, association, at, body } of items) { + if (!isUser || !MEMBERS.has(String(association).toUpperCase()) || !String(body ?? "").trim()) { + continue; + } + if (!newest.has(login) || at >= newest.get(login).at) { + newest.set(login, { at, body: String(body) }); + } + } + return [...newest] + .filter(([, { body }]) => HUMAN_VETO.test(body)) + .map(([login]) => login) + .sort(); +} + +// One REST issue comment, review or review comment as a vetoers item. +export const fromRest = (row) => ({ + login: String(row?.user?.login ?? ""), + userId: row?.user?.id, + state: row?.state, + commitId: row?.commit_id, + isUser: row?.user?.type === "User", + association: row?.author_association, + at: String(row?.submitted_at ?? row?.created_at ?? ""), + body: String(row?.body ?? ""), +}); + +// Whether `path` matches the hold-path `glob`. path.matchesGlob has no `dot` +// option, so `*` and `**` skip a segment opening with "."; both sides mark +// such a segment alike first, so `.github/**` holds `.github/a/.x` too. +const undot = (text) => String(text).replaceAll(/(^|\/)\./g, "$1\u0001"); +export const matchesHoldPath = (path, glob) => posix.matchesGlob(undot(path), undot(glob)); + +// Why the head's changed `files` hold it, or null. Null `holdPaths` or +// `files` (unreadable) holds it. +export function pathHold(files, holdPaths) { + if (!Array.isArray(holdPaths)) { + return "the hold paths could not be read"; + } + if (!Array.isArray(files)) { + return "its changed files could not all be read"; + } + if (typeof posix.matchesGlob !== "function") { + return "this Node has no path.matchesGlob, so its hold paths cannot be checked"; + } + const globs = [...holdPaths, ...HOLD_PATH_FLOOR]; + const held = files.find((path) => globs.some((glob) => matchesHoldPath(path, glob))); + return held === undefined ? null : `it changes ${held}, a hold path`; +} + +// The config's denied paths, or null unless a list of strings; pathHold adds +// the floor. +export function holdPathsOf(denied) { + return Array.isArray(denied) && denied.every((glob) => typeof glob === "string") ? denied : null; +} + +// The trusted people (numeric `humans` ids) whose newest deciding review of +// `head` among `items` approves it, sorted by login. +export function pathLifters(items, humans, head) { + const newest = new Map(); + for (const item of items) { + if (!item.isUser || !humans.has(item.userId) || item.commitId !== head) { + continue; + } + if (!["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(item.state)) { + continue; + } + if (!newest.has(item.userId) || item.at >= newest.get(item.userId).at) { + newest.set(item.userId, item); + } + } + return [...newest.values()] + .filter(({ state }) => state === "APPROVED") + .map(({ login }) => login) + .sort(); +} + +// Why the pull request's `body`, discussion `items` and changed `files` hold +// it; empty when nothing does. `lifters` approved the head, which lifts a hold +// path but not an unreadable file list or hold-path list. +export function holds(body, items, files, holdPaths, lifters = []) { + const reasons = []; + const pathReason = pathHold(files, holdPaths); + const readable = Array.isArray(files) && Array.isArray(holdPaths); + if (pathReason && !(readable && lifters.length)) { + reasons.push( + readable + ? `${pathReason}; an approving review of this head by a person on the trusted-actor list lifts it, then re-run ci-status` + : pathReason, + ); + } + if (BODY_HOLD.test(String(body ?? ""))) { + reasons.push("its body holds it (a line opening with 'Hold:', or 'do not merge'); removing that text lifts it"); + } + const vetoes = vetoers(items); + if (vetoes.length) { + reasons.push(`${vetoes.join(", ")} asked not to merge it; a later comment by the same person lifts it, then re-run ci-status`); + } + return reasons; +} + +// 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; + try { + if ( + !/^[1-9][0-9]*$/.test(String(number)) || + !bodyFile || + !discussionFile || + !filesFile || + !deniedFile || + !actorsFile || + !/^[0-9a-f]{40}$/.test(String(head)) + ) { + throw new Error( + "usage: merge-holds.mjs ", + ); + } + const lines = (file) => readFileSync(file, "utf8").split("\n").filter((line) => line.trim()); + const items = lines(discussionFile).map((line) => fromRest(JSON.parse(line))); + const files = lines(filesFile).flatMap((line) => JSON.parse(line).map(String)); + const holdPaths = holdPathsOf(JSON.parse(readFileSync(deniedFile, "utf8"))); + const actors = JSON.parse(readFileSync(actorsFile, "utf8"))?.actors; + if (!Array.isArray(actors)) { + throw new Error("the trusted-actor list has no actors"); + } + const humans = new Set( + actors.filter((actor) => actor?.kind === "human" && Number.isInteger(actor?.id)).map(({ id }) => id), + ); + const lifters = pathLifters(items, humans, head); + const reasons = holds(readFileSync(bodyFile, "utf8"), items, files, holdPaths, lifters); + const held = pathHold(files, holdPaths); + if (held && lifters.length && Array.isArray(holdPaths)) { + console.log(`#${number}: ${lifters.join(", ")} approved this head, which lifts its hold path (${escape(held)}).`); + } + for (const reason of reasons) { + console.log(`::error::#${number}: ${escape(reason)}.`); + } + if (!reasons.length) { + console.log(`#${number}: no body hold, no member veto and no hold path.`); + } + return reasons.length ? 1 : 0; + } catch (error) { + console.log(`::error::merge-holds: ${String(error?.message ?? error).replaceAll(/[\r\n%]/g, " ")}`); + return 2; + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + process.exitCode = main(); +} diff --git a/.github/scripts/pr-merge/workflow.test.mjs b/.github/scripts/pr-merge/workflow.test.mjs index 3a16f366c7..40cd2edfd8 100644 --- a/.github/scripts/pr-merge/workflow.test.mjs +++ b/.github/scripts/pr-merge/workflow.test.mjs @@ -147,6 +147,40 @@ test("pr-require-checks.yml never cancels a dispatched or scheduled run", () => assert.match(group, /github\.event\.pull_request\.number \|\| github\.run_id/); }); +// The native-merge plan's arm-merge item: the lanes App token arms a lane +// pull request as soon as the issue lane finishes, in pr-merge.yml, the one +// caller the token broker mints `merge` for; the merge job's gate never runs +// on that event. +test("the issue lane finishing wakes only the enable-auto-merge job", () => { + assert.match(merge, /\n {2}workflow_run: # zizmor: ignore\[dangerous-triggers\]\n {4}workflows: \[work-resolve-issue\]\n {4}types: \[completed\]\n/); + assert.match(job("merge"), /\n {6}&& github\.event_name != 'workflow_run'\n/); + const arm = job("enable-auto-merge"); + const armIf = /\n {4}if: >-\n([\s\S]*?)\n {4}[a-z-]+:/.exec(arm)[1]; + assert.match(armIf, /github\.event_name != 'workflow_run' \|\| github\.event\.workflow_run\.conclusion != 'skipped'/); + // A push to main is a merge landing, never a new lane pull request. + assert.match(armIf, /github\.event_name != 'push'/); + assert.match(arm, /\n {4}concurrency:\n {6}group: pr-merge-enable-auto-merge\n {6}queue: max\n/); +}); + +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]}`); + assert.deepEqual(steps, ["pr-merge/enable-auto-merge", "pr-merge/enable-auto-merge"]); + assert.match(arm, /node \.base\/\.github\/scripts\/pr-merge\/enable-auto-merge\.mjs/); + // A missing merge.report-only reaches the script as `null`, which it reads + // as report-only; the job must not turn it into `false`. + assert.match(arm, /REPORT_ONLY=\$\(yq '\.merge\."report-only"' "\$config"\)\n/); + assert.doesNotMatch(arm, /REPORT_ONLY=false/); + // The hold paths come from the config the job resolved. + assert.match(arm, /output-path: \$\{\{ runner\.temp \}\}\/resolved-config\.json\n/); + assert.match(arm, /\n {10}RESOLVED_CONFIG: \$\{\{ runner\.temp \}\}\/resolved-config\.json\n/); + 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"], + ); +}); + // The dispatch POST's retry, run under bash against a stub gh that answers // each POST in turn from `answers` ({status, headers, body}, or {stderr} for // no response at all, with gh's error text), a stub sleep that logs its seconds and a stub date. The diff --git a/.github/workflows/pr-merge.yml b/.github/workflows/pr-merge.yml index 55a58e51ac..857cc857ad 100644 --- a/.github/workflows/pr-merge.yml +++ b/.github/workflows/pr-merge.yml @@ -96,6 +96,14 @@ on: push: branches: [main] + # The issue lane finished, and may have opened a pull request: only the + # enable-auto-merge job runs on it, so the new pull request is armed at + # once rather than at the next scheduled run. The run is the main copy of + # this file and checks out no code from the issue run. + workflow_run: # zizmor: ignore[dangerous-triggers] + workflows: [work-resolve-issue] + types: [completed] + permissions: {} defaults: @@ -363,6 +371,7 @@ jobs: # out of the queue. if: >- github.ref == 'refs/heads/main' && github.event_name != 'push' + && github.event_name != 'workflow_run' && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) # One run at a time, and a running one is never cancelled: a cancelled run @@ -1196,6 +1205,137 @@ jobs: echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" fi + # Turns on GitHub's native auto-merge for each open lanes App pull request + # into main that closes an issue and carries no hold, and turns it off on + # one that now carries a hold (a `Hold:` line, do-not-merge, the human-gated + # label). Every event is a wake-up: the script lists the pull requests + # 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 + # 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) + # opened nothing. + if: >- + github.ref == 'refs/heads/main' && github.event_name != 'push' + && (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion != 'skipped') + && (github.event_name != 'pull_request_target' + || github.event.pull_request.head.repo.full_name == github.repository) + # A pending run is never replaced: a cancelled run leaves a CANCELLED + # check on the pull request that woke it. + concurrency: + group: pr-merge-enable-auto-merge + queue: max + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read # check out main + id-token: write # prove to the token broker which workflow this is + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/heads/main + path: .base + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .base/.node-version + package-manager-cache: false + + - name: Check the kill switch + id: kill + uses: ./.base/.github/actions/check-kill-switch # zizmor: ignore[self-repository] + with: + value: ${{ vars.CLAUDE_LANES_DISABLED }} + repo-value: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + + # The broker mints the merge token only for a listed human, or on the + # schedule; any other run stops here quietly, as the merge job's does. + - name: Check who started the run + id: who + if: steps.kill.outputs.proceed == 'true' + env: + EVENT: ${{ github.event_name }} + ACTOR_ID: ${{ github.actor_id }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + LIST: .base/.github/standards/trusted-actors/trusted-actors.json + run: | + set -euo pipefail + if [ "$EVENT" = schedule ]; then + echo "proceed=true" >>"$GITHUB_OUTPUT" + elif [[ $ACTOR_ID =~ ^[1-9][0-9]*$ ]] && + jq -e --argjson id "$ACTOR_ID" --arg login "$TRIGGERING_ACTOR" ' + [.actors[] | select(.kind == "human")] as $humans + | ($humans | any(.id == $id)) + and ($humans | any((.login | ascii_downcase) == ($login | ascii_downcase))) + ' "$LIST" >/dev/null; then + echo "proceed=true" >>"$GITHUB_OUTPUT" + else + echo "::notice::started by ${TRIGGERING_ACTOR:-an unknown account}, who is not a listed human; the next scheduled run arms what this one would" + echo "proceed=false" >>"$GITHUB_OUTPUT" + fi + + - name: Resolve the config + id: config + if: steps.who.outputs.proceed == 'true' + uses: ./.base/.github/actions/resolve-config # zizmor: ignore[self-repository] + with: + lane: pr-merge + activity: enable-auto-merge + base-path: .base + output-path: ${{ runner.temp }}/resolved-config.json + + - name: Request the lane token + id: lane-token + if: steps.config.outcome == 'success' + uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] + with: + broker-url: ${{ vars.LANES_BROKER_URL }} + audience: ${{ vars.LANES_BROKER_AUDIENCE }} + lane: pr-merge + activity: enable-auto-merge + effect: ${{ steps.config.outputs.effect }} + contents: ${{ steps.config.outputs.contents }} + pull-requests: ${{ steps.config.outputs.pull-requests }} + issues: ${{ steps.config.outputs.issues }} + superseded-ok: true + + # 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 + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + KILL_SWITCH: ${{ steps.kill.outputs.proceed == 'true' && 'proceed' || '' }} + RESOLVED_CONFIG: ${{ runner.temp }}/resolved-config.json + run: | + set -euo pipefail + config=.base/docs/conventions/pr-pipeline.yaml + REPORT_ONLY=$(yq '.merge."report-only"' "$config") + LANES_BOT_LOGIN=$(yq '.merge."agent-identity" // ""' "$config") + export REPORT_ONLY LANES_BOT_LOGIN + node .base/.github/scripts/pr-merge/enable-auto-merge.mjs + + - name: Revoke the lane token + if: always() && steps.lane-token.outputs.token != '' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.lane-token.outputs.token }} + run: | + status=$(printf 'Authorization: Bearer %s\n' "$GH_TOKEN" | + curl -sS -o /dev/null -w '%{http_code}' --max-time 30 -X DELETE -H @- \ + -H 'Accept: application/vnd.github+json' "$GITHUB_API_URL/installation/token" || true) + if [ "$status" = 204 ]; then + echo "the lane token is revoked" + else + echo "::warning::the lane token was not revoked (DELETE $status); it expires within the hour" + fi + # Starts the full CI run that verifies main. Starting a workflow needs # `actions: write`, which the lane App token does not have, so this job does # it with its own GitHub token, in two cases: diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index b553872360..097fa65b3e 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3109,12 +3109,12 @@ jobs: # (`rate-limit-wait-seconds` on check-contract and aggregate-results), 45 s # 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 three reads and - # the review-lane step's four, under two minutes, plus the cost-hold step's - # reads: three for most pull requests, which stop at the label events; for a - # lane-labeled one whose body cites a decision, about ten, and two more per - # review run since the window opened. - timeout-minutes: 9 + # 15 s of retry sleeps for each of the hold-lift step's three reads, the + # review-lane step's four and the lanes-hold step's eight, about four + # minutes, plus the cost-hold step's reads: three for most pull requests, + # which stop at the label events; for a lane-labeled one whose body cites a + # decision, about ten, and two more per review run since the window opened. + timeout-minutes: 11 permissions: # `yield-to-full-run` lists this workflow's runs on the head SHA, and # `rerun-contract-only-siblings` re-runs the red contract-only ones once @@ -3795,6 +3795,141 @@ jobs: fi [ "$held" -eq 0 ] || exit 1 echo "#$pr carries neither 'do-not-merge' nor '$human_gated'." + # The lanes-hold step below runs node; pin it to main's .node-version, + # as every other Node job is, rather than the runner image's default. + - name: Check out main's Node pin + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: .base + persist-credentials: false + sparse-checkout: .node-version + sparse-checkout-cone-mode: false + - name: Set up Node + if: ${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: .base/.node-version + # A LANES APP PULL REQUEST IS HELD BY ITS TEXT TOO. pr-merge.yml's + # enable-auto-merge job arms native auto-merge on lanes App pull + # requests, which then merge without pr-merge's gate, so this step fails + # on the holds that gate honors: a body hold, a member's veto comment, + # and a changed file or renamed-from path matching the merge lane's + # denied paths (pr-pipeline.yaml merge.diff-check.denied-paths, as main + # has it, plus merge-holds.mjs's HOLD_PATH_FLOOR), so a push after arming is + # held on its own pull request event; a person on the trusted-actor list + # (as main has it) lifts a hold path by approving the head, then re-runs + # ci-status. All by the rules in + # .github/scripts/pr-merge/merge-holds.mjs, + # which this step runs as main has it so a pull request cannot rewrite + # its own hold: in the queue at the group's base commit, and on a pull + # request event at the live base commit of a pull request into main. A + # pull request into any other branch is never armed and is not read, so + # it cannot choose a branch whose module runs here with this job's token. On a pull request event and in the merge queue + # (the pull request named in the group's ref, as the step above reads + # it), the body, comments, reviews and inline comments are read live, + # never from the payload a re-run replays, and so are the changed files; + # a list shorter than changed_files fails closed. A body edit re-runs this job; + # a comment does not, so a veto posted while armed is enforced when the + # queue builds the group, and after a veto is lifted ci-status needs a + # re-run. Only the lanes App's pull requests are read, by numeric id + # (pr-pipeline.yaml merge.agent-identity, as trusted-actors.json lists + # 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. + # 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 }} + GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + lanes_app_id=337595936 + pr=$PR_NUMBER + if [ "$GITHUB_EVENT_NAME" = merge_group ]; then + pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") + if [ -z "$pr" ]; then + echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'." + exit 1 + fi + fi + if [[ ! $pr =~ ^[1-9][0-9]*$ ]]; then + echo "::error::the pull request number '${pr//%/%25}' is not a number." + exit 1 + fi + read_api() { + local attempt body + for attempt in 1 2 3; do + if body=$(gh api "${@:2}"); then + printf '%s\n' "$body" + return 0 + fi + [ "$attempt" -lt 3 ] || { echo "::error::could not read $1; re-run this run." >&2; return 1; } + sleep $((attempt * 5)) + done + } + pull=$(read_api "#$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr") || exit 1 + if ! jq -e '(.user.id | numbers) and (.user.type | strings)' <<<"$pull" >/dev/null; then + echo "::error::the author of #$pr could not be read." + exit 1 + fi + if ! jq -e --argjson id "$lanes_app_id" '.user.id == $id and .user.type == "Bot"' <<<"$pull" >/dev/null; then + echo "#$pr is not the lanes App's; pr-merge's gate or a person reads its holds." + exit 0 + fi + BASE_SHA=$GROUP_BASE_SHA + if [ "$GITHUB_EVENT_NAME" != merge_group ]; then + if [ "$(jq -r '.base.ref' <<<"$pull")" != main ]; then + echo "#$pr is not into main, so native auto-merge never arms it." + exit 0 + fi + BASE_SHA=$(jq -r '.base.sha' <<<"$pull") + fi + if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the base commit '$BASE_SHA' is not a commit SHA." + exit 1 + fi + module=.github/scripts/pr-merge/merge-holds.mjs + read_api "$module at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/$module?ref=$BASE_SHA" >"$RUNNER_TEMP/merge-holds.mjs" || exit 1 + jq -r '.body // ""' <<<"$pull" >"$RUNNER_TEMP/pr-body.txt" + config=docs/conventions/pr-pipeline.yaml + read_api "$config at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/$config?ref=$BASE_SHA" >"$RUNNER_TEMP/pr-pipeline.yaml" || exit 1 + yq -o=json '.merge."diff-check"."denied-paths" // []' "$RUNNER_TEMP/pr-pipeline.yaml" >"$RUNNER_TEMP/denied-paths.json" || { + echo "::error::cannot read merge.diff-check.denied-paths from $config at $BASE_SHA." + exit 1 + } + read_api "the files of #$pr" "repos/$GITHUB_REPOSITORY/pulls/$pr/files?per_page=100" --paginate \ + --jq '.[] | [.filename, (.previous_filename // empty)] | @json' >"$RUNNER_TEMP/files.jsonl" || exit 1 + changed=$(jq -er '.changed_files | numbers' <<<"$pull") || { echo "::error::the changed-file count of #$pr could not be read."; exit 1; } + if [ "$(grep -c . "$RUNNER_TEMP/files.jsonl")" -lt "$changed" ]; then + echo "::error::GitHub listed fewer files than #$pr changes, so its hold paths cannot be checked; a person merges it." + exit 1 + fi + head=$(jq -r '.head.sha' <<<"$pull") + if [[ ! $head =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::the head of #$pr could not be read." + exit 1 + fi + actors=.github/standards/trusted-actors/trusted-actors.json + read_api "$actors at $BASE_SHA" -H 'Accept: application/vnd.github.raw+json' \ + "repos/$GITHUB_REPOSITORY/contents/$actors?ref=$BASE_SHA" >"$RUNNER_TEMP/trusted-actors.json" || exit 1 + : >"$RUNNER_TEMP/discussion.jsonl" + for kind in "issues/$pr/comments" "pulls/$pr/reviews" "pulls/$pr/comments"; do + 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 + 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") || { + 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; } # 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/AGENTS.md b/AGENTS.md index ad3f5fc9c5..9c991488f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ Title every pull request in Conventional Commits form, `[()]: --add-label do-not-merge`) before writing any explanation. "Do not merge" in the body -or a comment is not enforced by `ci-status`, which reads only the label; the babysit merge gate +or a comment is not enforced by `ci-status`, which reads only the label, except on a lanes App pull +request, where it also fails on a body hold or a member's veto comment; the babysit merge gate still respects a body hold and human comments, so never override one. The hold convention is in `docs/conventions/loop-lane/README.md`. diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index 1d3fde744c..199d92ff01 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -360,6 +360,22 @@ activities: # Reads PR bodies, labels and comments for holds. reads-untrusted: true inputs: [pr, head-sha] + enable-auto-merge: + # Turns on GitHub's native auto-merge (squash, at the head it read) for + # each open lanes App pull request into main that opens with `Closes #` + # and carries no hold (a `Hold:` line, do-not-merge, the human-gated + # label, a member's "do not merge" comment, a head that changes a denied + # path), and turns it off on an armed one that now carries a hold; + # auto-merge a person turned off stays off. No + # model. The enable-auto-merge job of .github/workflows/pr-merge.yml runs + # it when the issue lane finishes, on a listed human's PR edit, and on the + # schedule. + script: .github/scripts/pr-merge/enable-auto-merge.mjs + effect: merge + gating: advisory + # Reads PR bodies, labels and comments for holds. + reads-untrusted: true + inputs: [pr, head-sha] label-unblocked: # When an issue closes (or, hourly, for each issue a listed human or a # merge closed in the last three hours), labels agent-ready each issue it @@ -476,6 +492,8 @@ lanes: stage: merge slots: - activity: merge-pr + # In its own job of pr-merge.yml, with its own token. + - activity: enable-auto-merge # Started by an issue closing and by an hourly sweep of recent closes # (.github/workflows/work-unblock.yml). work-unblock: diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 6dac1ea6a9..878c6cd0d4 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1025,6 +1025,43 @@ such as a demotion record, a ruleset, an edit to a comment older than the newest outside the keyed paths, delays re-gating by at most those limits; a skip never merges, enqueues or drops an escalation. +#### Native auto-merge + +The lane's `enable-auto-merge` activity, the `enable-auto-merge` job of `pr-merge.yml` with its +own lanes App token (`.github/scripts/pr-merge/enable-auto-merge.mjs`, no model), turns on GitHub's +native auto-merge (`enablePullRequestAutoMerge`, squash, `expectedHeadOid` the head it read) for +each open lanes App pull request into main from a branch of this repository that is not a draft, +opens with `Closes #`, and carries no hold: no `Hold:` line or "do not merge" in its body +(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 +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 +adding a `Hold:` line disarms at once; and the 15-minute schedule arms or disarms what the events +missed. The token broker mints `merge` only for `pr-merge.yml`, and outside the schedule only for a +listed human's run, so an issue run the lanes App started is armed by the next scheduled run. While +`merge.report-only` is true it reports each decision and writes nothing. + +`ci-status` enforces the same holds on every lanes App pull request, the two text holds and the +hold paths, so one added after arming stops the merge without waiting for this job: its step +`Fail a lanes App pull request its body or a member holds` runs +`.github/scripts/pr-merge/merge-holds.mjs` as main has it, the module `enable-auto-merge.mjs` arms +by, over the live body, discussion and changed files (renamed-from paths included) against main's +`merge.diff-check.denied-paths` plus the paths resolve-config always adds and the rest of the merge +gate's HIGH floor (`CODEOWNERS`, `.gitmodules`, every plugin manifest), on each pull request +event and in the merge queue, where a red group removes the pull request. A push that adds a hold +path fails `ci-status` on its own head. A person on the trusted-actor list (as main has it) lifts a +hold path by approving that head: their newest review of it that approves, requests changes or was +dismissed must approve. Then re-run `ci-status`, since a review does not. The approval lifts no +body hold or veto, and arming still skips the pull request, so that person merges it. The module is read at the pull request's base commit, so a lanes App pull +request whose base predates the module fails `ci-status` closed until it is updated with main. +Its own changes are held: `merge-holds.mjs` and this workflow sit under `.github/**`, a path +resolve-config always denies, so a lanes App pull request cannot weaken the rule and merge itself. A body edit re-runs +`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. + ### Dependabot pull requests No Dependabot pull request waits on a human diff --git a/scripts/ci-lanes-pr-hold.test.sh b/scripts/ci-lanes-pr-hold.test.sh new file mode 100755 index 0000000000..1e4f5f467b --- /dev/null +++ b/scripts/ci-lanes-pr-hold.test.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# Runs the `Fail a lanes App pull request its body or a member holds` step body +# of .github/workflows/pr-require-checks.yml's ci-status under the shell Actions +# gives a step with no `shell:` (bash -e), against stub `gh` and `sleep`. +# +# Native auto-merge (pr-merge.yml's enable-auto-merge job) merges a lanes App +# pull request without pr-merge's gate, so ci-status holds it on the holds +# that gate honors: a body hold (a `Hold:` line or "do not merge"), a member's +# newest comment saying not to merge, and a changed or renamed-from path +# matching the merge lane's denied paths. It reads them live, on a pull +# request event and in the merge queue, runs the base commit's +# merge-holds.mjs, and fails closed when it cannot tell. +# test-scope: .github/workflows/pr-require-checks.yml .github/scripts/pr-merge/merge-holds.mjs docs/conventions/pr-pipeline.yaml +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/pr-require-checks.yml" +MODULE="$ROOT/.github/scripts/pr-merge/merge-holds.mjs" + +# shellcheck source=lib/test-harness.sh +. "$ROOT/scripts/lib/test-harness.sh" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +NAME='Fail a lanes App pull request its body or a member holds' +step="$(awk -v name=" - name: $NAME" '$0 == name { on = 1; print; next } + on && /^ - / { exit } + on { print }' "$WORKFLOW")" +awk '/^ run: \|$/ { on = 1; next } + on && /^ / { print substr($0, 11); next } + on && /^[[:blank:]]*$/ { print ""; next } + on { exit }' <<<"$step" >"$TMP_ROOT/body.sh" + +if [[ ! -s "$TMP_ROOT/body.sh" ]]; then + fail "no '$NAME' step with a run: | body in $WORKFLOW" + test_harness::report + exit 1 +fi +if [[ "$step" == *"if: \${{ !cancelled() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}"* ]]; then + ok "step runs on pull_request and merge_group" +else + fail "step lost its pull_request and merge_group condition" +fi +if [[ "$step" == *"continue-on-error"* ]]; then + fail "step must not continue on error: its exit code is the hold" +else + ok "step fails the job on its exit code" +fi +if [[ "$step" == *"github.event.pull_request.body"* ]]; then + fail "step reads the body from the event payload, which a re-run replays stale" +else + ok "step reads the body live" +fi +if [[ "$step" == *"GROUP_BASE_SHA: \${{ github.event.merge_group.base_sha }}"* && "$step" != *"pull_request.base"* ]]; then + ok "step takes only the queue's base from the payload; a pull request's base is read live" +else + fail "step reads a pull request's base from the payload" +fi +if grep -qxF " - name: Set up Node" "$WORKFLOW" && [[ "$(grep -nxF -e " - name: Set up Node" -e " - name: $NAME" "$WORKFLOW" | cut -d: -f2- | paste -sd'|')" == *"Set up Node| - name: $NAME" ]]; then + ok "a prior step pins Node right before the step" +else + fail "no 'Set up Node' step right before the step" +fi +order="$(grep -nE "^ ci-status:$|^ - name: ($NAME|Aggregate lane results)$" "$WORKFLOW" | cut -d: -f1 | paste -sd' ')" +read -r job_line hold_line agg_line <<<"$order" +if [[ -n "${agg_line:-}" && "$job_line" -lt "$hold_line" && "$hold_line" -lt "$agg_line" ]]; then + ok "step sits in ci-status before the aggregate" +else + fail "step is not inside ci-status before the aggregate (lines: $order)" +fi +# The step names the lanes App by numeric id; it must be the trusted-actor +# entry for pr-pipeline.yaml's merge.agent-identity. +identity="$(sed -nE 's/^ agent-identity: (.+)$/\1/p' "$ROOT/docs/conventions/pr-pipeline.yaml")" +want_id="$(jq -r --arg login "$identity" '.. | objects | select(.login? == $login) | .id' "$ROOT/.github/standards/trusted-actors/trusted-actors.json")" +if [[ -n "$want_id" && "$(grep -c "lanes_app_id=$want_id\$" "$TMP_ROOT/body.sh")" -eq 1 ]]; then + ok "step's lanes App id is merge.agent-identity's ($identity, $want_id)" +else + fail "step's lanes App id is not $identity's trusted-actor id '$want_id'" +fi + +# The stub logs its arguments. A pull read prints STUB_PULL. A contents read +# of merge-holds.mjs prints the working tree's module, or answers 404 when +# STUB_NO_MODULE is set. A discussion read fails its first STUB_FAILS calls, +# then prints each row of STUB_ISSUE, STUB_REVIEWS or STUB_INLINE (JSON +# arrays) as one line, as `--jq '.[] | @json'` does. A contents read of +# pr-pipeline.yaml prints STUB_CONFIG, or answers 404 when STUB_NO_CONFIG is +# 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. +mkdir -p "$TMP_ROOT/bin" +cat >"$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$STUB_LOG" +rows() { jq -c '.[]' <<<"${1:-[]}"; } +discussion() { + [[ "$(grep -cE '/(comments|reviews)\?' "$STUB_LOG")" -gt "${STUB_FAILS:-0}" ]] || { echo 'gh: Server Error (HTTP 502)' >&2; exit 1; } +} +case "$*" in +*/contents/.github/scripts/pr-merge/merge-holds.mjs*) + [[ -z "${STUB_NO_MODULE:-}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + cat "$STUB_MODULE" + ;; +*/contents/docs/conventions/pr-pipeline.yaml*) + [[ -z "${STUB_NO_CONFIG:-}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + printf '%s\n' "$STUB_CONFIG" + ;; +*/pulls/*/files\?*) rows "$STUB_FILES" ;; +*/contents/.github/standards/trusted-actors/trusted-actors.json*) + [[ -z "${STUB_NO_ACTORS:-}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + cat "$STUB_ACTORS" + ;; +*/issues/*/comments\?*) discussion; rows "${STUB_ISSUE:-}" ;; +*/pulls/*/reviews\?*) discussion; rows "${STUB_REVIEWS:-}" ;; +*/pulls/*/comments\?*) discussion; rows "${STUB_INLINE:-}" ;; +*/pulls/*) printf '%s\n' "$STUB_PULL" ;; +*) exit 1 ;; +esac +EOF +printf '#!/usr/bin/env bash\n' >"$TMP_ROOT/bin/sleep" +chmod +x "$TMP_ROOT/bin/gh" "$TMP_ROOT/bin/sleep" + +SHA=0123456789abcdef0123456789abcdef01234567 +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}'; } +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. +approval() { jq -nc --arg login "$1" --argjson id "$2" --arg state "$3" --arg at "$4" --arg commit "$5" \ + '{user: {login: $login, id: $id, type: "User"}, author_association: "OWNER", state: $state, submitted_at: $at, commit_id: $commit, body: ""}'; } +review() { jq -nc --arg login "$1" --arg assoc "$2" --arg at "$3" --arg body "$4" \ + '{user: {login: $login, type: "User"}, author_association: $assoc, submitted_at: $at, body: $body}'; } + +# expect