diff --git a/.changes/pr-pipeline/fix-lane-stopped-branch-reuse-5e503c34.md b/.changes/pr-pipeline/fix-lane-stopped-branch-reuse-5e503c34.md new file mode 100644 index 00000000000..b971f91b92f --- /dev/null +++ b/.changes/pr-pipeline/fix-lane-stopped-branch-reuse-5e503c34.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- A work-resolve-issue run now deletes the issue's leftover lane branches from earlier stopped runs: a branch whose run posted an outcome marker, whose commits are all the lanes App's, and that has no pull request in any state. `issue-prior` names them from the reads it already makes, sparing the branch the run reuses, resumes or starts fresh past and every branch of the run itself, the issue-fields job publishes only the names its own re-derived check also names, and a new `prune-lane-branches` job, the lane's only `contents: write`, re-checks every named branch (pull requests from it in any state, open ones into it) in one GraphQL read and deletes each with one `updateRefs` at the tip it read. The stale-branch comment now says the next run deletes the branch instead of promising no lane does. diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index e3bf4b0cc59..8a612c233b8 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -3937,7 +3937,7 @@ async function issueStep(env) { let failed = null; const core = { setOutput: (name, value) => (outputs[name] = value), setFailed: (message) => (failed = message) }; const { createRequire } = await import("node:module"); - const full = { LANE: "work-resolve-issue", ISSUE_NUMBER: "7", STARTED: "true", REUSE_BRANCH: "", RESUME_BRANCH: "", RESUME_TIP: "", BASE_SHA: "b".repeat(40), ...env }; + const full = { LANE: "work-resolve-issue", ISSUE_NUMBER: "7", STARTED: "true", REUSE_BRANCH: "", RESUME_BRANCH: "", RESUME_TIP: "", BASE_SHA: "b".repeat(40), PRUNE_BRANCHES: "", ...env }; await new AsyncFunction("require", "core", "github", "context", "process", step.with.script)(createRequire(import.meta.url), core, {}, SCAN_CONTEXT, { env: full }); return { outputs, failed }; } @@ -3961,11 +3961,11 @@ test("pr-run-activity-read.yml: issue-fields runs after any run job result but s // A refusal ends the job green, so its outputs reach the caller. for (const step of job.steps) assert.equal(step["continue-on-error"], true, step.name); const outputs = read.on.workflow_call.outputs; - for (const name of ["issue-run-started", "reuse-branch", "resume-branch", "resume-tip", "base-sha"]) { + for (const name of ["issue-run-started", "reuse-branch", "resume-branch", "resume-tip", "base-sha", "prune-branches"]) { assert.equal(outputs[`scan-${name}`].value, `\${{ jobs.issue-fields.outputs.${name} }}`, name); } assert.equal(job.outputs["issue-run-started"], "${{ steps.issue.outputs.issue-run-started }}"); - for (const name of ["checked", "reuse-branch", "resume-branch", "resume-tip", "base-sha", "start-sha"]) { + for (const name of ["checked", "reuse-branch", "resume-branch", "resume-tip", "base-sha", "start-sha", "prune-branches"]) { assert.equal(job.outputs[name], `\${{ steps.checked.outputs.${name} }}`, name); } // The scan job publishes nothing in issue mode unless the fields were checked. @@ -3981,7 +3981,10 @@ test("issue-fields: the issue step holds each field to its closed set and to the const branch = "work-resolve-issue/issue-7-123-1"; const fresh = await issueStep({}); assert.equal(fresh.failed, null); - assert.deepEqual(fresh.outputs, { "issue-run-started": "true", claimed: "false", "reuse-branch": "", "resume-branch": "", "resume-tip": "", "base-sha": base }); + assert.deepEqual(fresh.outputs, { "issue-run-started": "true", claimed: "false", "reuse-branch": "", "resume-branch": "", "resume-tip": "", "base-sha": base, "prune-branches": "" }); + const older = "work-resolve-issue/issue-7-100-1 work-resolve-issue/issue-7-101-2"; + const pruning = await issueStep({ PRUNE_BRANCHES: older }); + assert.deepEqual([pruning.failed, pruning.outputs.claimed, pruning.outputs["prune-branches"]], [null, "false", older]); const resumed = await issueStep({ RESUME_BRANCH: branch, RESUME_TIP: tip }); assert.deepEqual([resumed.failed, resumed.outputs.claimed, resumed.outputs["resume-tip"]], [null, "true", tip]); const reused = await issueStep({ STARTED: "false", REUSE_BRANCH: branch }); @@ -3997,6 +4000,14 @@ test("issue-fields: the issue step holds each field to its closed set and to the ["a resume branch with no tip", { RESUME_BRANCH: branch }], ["a resume that started no skill", { STARTED: "false", RESUME_BRANCH: branch, RESUME_TIP: tip }], ["a started run with no base", { BASE_SHA: "" }], + ["another issue's branch to prune", { PRUNE_BRANCHES: "work-resolve-issue/issue-8-100-1" }], + ["the default branch to prune", { PRUNE_BRANCHES: "main" }], + ["the resume branch to prune", { RESUME_BRANCH: branch, RESUME_TIP: tip, PRUNE_BRANCHES: branch }], + ["the reuse branch to prune", { STARTED: "false", REUSE_BRANCH: branch, PRUNE_BRANCHES: branch }], + ["a branch named twice to prune", { PRUNE_BRANCHES: `${branch} ${branch}` }], + ["more than 20 branches to prune", { PRUNE_BRANCHES: Array.from({ length: 21 }, (_, i) => `work-resolve-issue/issue-7-${100 + i}-1`).join(" ") }], + ["a second line in the prune list", { PRUNE_BRANCHES: `${branch}\nchecked=true` }], + ["an empty name in the prune list", { PRUNE_BRANCHES: ` ${branch}` }], ]) { const result = await issueStep(env); assert.match(String(result.failed), /^bad-output: /, name); @@ -4031,13 +4042,13 @@ test("issue-fields: a branch claim is published only when the re-derived earlier const output = join(root, "output"); writeFileSync(priorOut, priorLines); writeFileSync(output, ""); - const result = runStep(checked, root, { CLAIMED: "true", REUSE_BRANCH: "", RESUME_BRANCH: "", RESUME_TIP: "", BASE_SHA: base, ...claim, PRIOR_OUT: priorOut, GITHUB_OUTPUT: output }); + const result = runStep(checked, root, { CLAIMED: "true", REUSE_BRANCH: "", RESUME_BRANCH: "", RESUME_TIP: "", BASE_SHA: base, PRUNE_BRANCHES: "", ...claim, PRIOR_OUT: priorOut, GITHUB_OUTPUT: output }); return { status: result.status, stdout: result.stdout, outputs: outputLines(output) }; }; const resume = { RESUME_BRANCH: branch, RESUME_TIP: tip }; const same = publish(resume, `proceed=true\nreuse-branch=\nresume-branch=${branch}\nresume-tip=${tip}\nresume-cause=turn-cap\n`); assert.equal(same.status, 0, same.stdout); - assert.deepEqual(same.outputs, { checked: "true", "reuse-branch": "", "resume-branch": branch, "resume-tip": tip, "base-sha": base, "start-sha": tip }); + assert.deepEqual(same.outputs, { checked: "true", "reuse-branch": "", "resume-branch": branch, "resume-tip": tip, "base-sha": base, "start-sha": tip, "prune-branches": "" }); for (const [name, lines] of [ ["another tip", `proceed=true\nreuse-branch=\nresume-branch=${branch}\nresume-tip=${"e".repeat(40)}\n`], ["no resume now (the branch is rejected or not the App's)", "proceed=true\nreuse-branch=\n"], @@ -4052,6 +4063,32 @@ test("issue-fields: a branch claim is published only when the re-derived earlier assert.equal(reuse.status, 1, "a reuse the check does not choose"); const fresh = publish({ CLAIMED: "false" }, ""); assert.deepEqual([fresh.status, fresh.outputs["start-sha"]], [0, base]); + // A branch to prune is published only when the re-derived check names it + // too: a forged name, or a re-derive that wrote nothing, publishes none. + const older = "work-resolve-issue/issue-7-100-1"; + const forged = "work-resolve-issue/issue-7-101-1"; + const pruning = publish({ CLAIMED: "false", PRUNE_BRANCHES: `${older} ${forged}` }, `proceed=true\nreuse-branch=\nprune-branches=${older} work-resolve-issue/issue-7-99-1\n`); + assert.deepEqual([pruning.status, pruning.outputs["prune-branches"]], [0, older]); + const underived = publish({ CLAIMED: "false", PRUNE_BRANCHES: older }, ""); + assert.deepEqual([underived.status, underived.outputs.checked, underived.outputs["prune-branches"]], [0, "true", ""]); + // A plain fresh run skips the re-derive, so its file is never written. + for (const prune of ["", older]) { + const output = join(root, "output"); + writeFileSync(output, ""); + const result = runStep(checked, root, { CLAIMED: "false", REUSE_BRANCH: "", RESUME_BRANCH: "", RESUME_TIP: "", BASE_SHA: base, PRUNE_BRANCHES: prune, PRIOR_OUT: join(root, "missing"), GITHUB_OUTPUT: output }); + const outputs = outputLines(output); + assert.deepEqual([result.status, outputs.checked, outputs["prune-branches"]], [0, "true", ""], `no re-derive file, prune list "${prune}"`); + } + const baseStep = job.steps.find((s) => s.id === "base"); + const ctx = (claimed, prune) => ({ "steps.issue.outcome": "success", "steps.issue.outputs.claimed": claimed, "steps.issue.outputs.prune-branches": prune, "github.event.repository.default_branch": "main" }); + assert.equal(evaluate(baseStep.if, ctx("false", older)), true, "a prune list is re-derived"); + assert.equal(evaluate(baseStep.if, ctx("false", "")), false, "nothing to re-derive"); + // The re-derive runs whenever the base checkout did. + const node = job.steps.find((s) => s.id === "node"); + for (const outcome of ["success", "failure", "skipped"]) { + assert.equal(evaluate(node.if, { "steps.base.outcome": outcome }), outcome === "success", `node after a ${outcome} checkout`); + assert.equal(evaluate(prior.if, { "steps.node.outcome": outcome }), outcome === "success", `re-derive after a ${outcome} node setup`); + } }); // lane.json is the runner's record: an issue run that started carries it, @@ -4096,12 +4133,47 @@ test("pr-run-activity-read.yml: the reuse summary job takes code from the defaul assert.equal(runs(ctx("", "work-resolve-issue/issue-7-1-1")), false, "an unchecked reuse branch runs no summary"); }); +// The prune job (#7948): the one job that deletes a lane branch, with the +// write grant alone and code from the default branch tip only, taking the +// branches to prune and the reuse and resume branches it spares from the +// call that ran last, and this run's id from the run itself. +test("work-resolve-issue.yml: the prune job deletes from the default branch tip with contents write, only when branches are named", () => { + const job = workflow("work-resolve-issue.yml").jobs["prune-lane-branches"]; + assert.deepEqual(job.needs, ["resolve-issue", "resolve-issue-subscription", "resolve-issue-fallback"]); + assert.deepEqual(job.permissions, { contents: "write", "pull-requests": "read" }); + const checkout = job.steps.find((step) => step.name === "Check out the default branch tip"); + assert.equal(checkout.with.ref, "${{ github.event.repository.default_branch }}"); + assert.equal(checkout.with["persist-credentials"], false); + const del = job.steps.find((step) => step.name === "Delete the stopped runs' lane branches"); + assert.equal(del.run, "node .base/.github/scripts/work-resolve-issue/prune-branches.mjs"); + assert.equal(del.env.RUN_ID, "${{ github.run_id }}"); + assert.equal(del.env.GITHUB_TOKEN, "${{ github.token }}"); + for (const [name, output] of [["PRUNE_BRANCHES", "scan-prune-branches"], ["REUSE_BRANCH", "scan-reuse-branch"], ["RESUME_BRANCH", "scan-resume-branch"]]) { + assert.match(del.env[name], new RegExp(`needs\\.resolve-issue\\.outputs\\.${output} \\|\\| '' \\}\\}$`), name); + } + const runs = (first, fallback = "") => + evaluate(job.if.replace(/^\$\{\{ |\s*\}\}$/g, "").replace(/!cancelled\(\) && /, ""), { + "needs.resolve-issue.outputs.scan-prune-branches": first, + "needs.resolve-issue-fallback.result": fallback === "" ? "skipped" : "success", + "needs.resolve-issue-fallback.outputs.scan-prune-branches": fallback, + "needs.resolve-issue-subscription.result": "skipped", + "needs.resolve-issue-subscription.outputs.scan-prune-branches": "", + }); + assert.equal(runs("work-resolve-issue/issue-7-100-1"), true); + assert.equal(runs("", "work-resolve-issue/issue-7-100-1"), true, "the fallback call's list"); + assert.equal(runs(""), false, "nothing named runs no job"); + // No other job of the lane holds contents: write. + for (const [name, other] of Object.entries(workflow("work-resolve-issue.yml").jobs)) { + if (name !== "prune-lane-branches") assert.notEqual(other.permissions?.contents, "write", name); + } +}); + test("work-resolve-issue.yml: every resolve-issue output a job reads is the read runner's scan or issue-fields job's", () => { const { jobs } = workflow("work-resolve-issue.yml"); const outputs = workflow("pr-run-activity-read.yml").on.workflow_call.outputs; const read = (call) => [...new Set([...JSON.stringify(jobs).matchAll(new RegExp(`needs\\.${call}\\.outputs\\.([\\w-]+)`, "g"))].map((m) => m[1]))].sort(); const names = read("resolve-issue"); - assert.deepEqual(names, ["scan-base-sha", "scan-collect-refused", "scan-issue-run-started", "scan-outcome-uploaded", "scan-patch-attempt", "scan-patch-sha256", "scan-patch-state", "scan-reply-attempt", "scan-resume-branch", "scan-resume-tip", "scan-reuse-branch"]); + assert.deepEqual(names, ["scan-base-sha", "scan-collect-refused", "scan-issue-run-started", "scan-outcome-uploaded", "scan-patch-attempt", "scan-patch-sha256", "scan-patch-state", "scan-prune-branches", "scan-reply-attempt", "scan-resume-branch", "scan-resume-tip", "scan-reuse-branch"]); // The subscription call's are the same outputs, read in the same picks. assert.deepEqual(read("resolve-issue-subscription"), names); for (const name of names) assert.match(outputs[name].value, /^\$\{\{ jobs\.(scan|issue-fields)\.outputs\.[\w-]+ \}\}$/, name); diff --git a/.github/scripts/work-resolve-issue/prune-branches.mjs b/.github/scripts/work-resolve-issue/prune-branches.mjs new file mode 100644 index 00000000000..20dbd2ad13c --- /dev/null +++ b/.github/scripts/work-resolve-issue/prune-branches.mjs @@ -0,0 +1,97 @@ +// work-resolve-issue.yml's prune-lane-branches job: deletes the lane branches +// of stopped runs that report-outcome.mjs --find-prior named (pruneBranches), +// which the read runner's issue-fields job held to the lane-branch shape. +// The run job that named them ran the model, so the list is only a claim: +// this script drops any name that is not this issue's lane branch, is this +// run's own (any attempt), or is the reuse or resume branch, then reads every +// remaining branch in one GraphQL request and keeps any that backs a pull +// request in any state, is the base of an open one, is gone, or reads oddly. Each delete is one GraphQL +// updateRefs whose beforeOid is the tip read, so a push after the read makes +// GitHub refuse it. A failed read deletes nothing; a refused delete is logged +// and the next branch still goes. Nothing here fails the run. +// +// Environment: GITHUB_TOKEN, GITHUB_API_URL, REPOSITORY, ISSUE_NUMBER, +// RUN_ID, PRUNE_BRANCHES (space-separated), REUSE_BRANCH, RESUME_BRANCH. +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { client, DELETE_REF, ZERO_OID } from "../branch-cleanup/cleanup.mjs"; +import { isLaneBranch, PRUNE_CAP } from "./report-outcome.mjs"; + +const SHA = /^[0-9a-f]{40}$/; + +// The names this run may delete, before any read. +export function claimed(env) { + if (!/^[1-9][0-9]{0,9}$/.test(env.ISSUE_NUMBER ?? "")) throw new Error("ISSUE_NUMBER is not an issue number"); + if (!/^[1-9][0-9]*$/.test(env.RUN_ID ?? "")) throw new Error("RUN_ID is not a run id"); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(env.REPOSITORY ?? "")) throw new Error("REPOSITORY is not owner/repo"); + const own = `work-resolve-issue/issue-${env.ISSUE_NUMBER}-${env.RUN_ID}-`; + const spared = new Set([env.REUSE_BRANCH, env.RESUME_BRANCH].filter(Boolean)); + const names = [...new Set(String(env.PRUNE_BRANCHES ?? "").split(/\s+/).filter(Boolean))]; + return names.filter((name) => isLaneBranch(name, env.ISSUE_NUMBER) && !name.startsWith(own) && !spared.has(name)).slice(0, PRUNE_CAP); +} + +const readQuery = (count) => { + const vars = Array.from({ length: count }, (_, i) => `, $b${i}: String!, $n${i}: String!`).join(""); + const refs = Array.from( + { length: count }, + (_, i) => + `b${i}: ref(qualifiedName: $b${i}) { target { oid } associatedPullRequests(states: [OPEN, CLOSED, MERGED]) { totalCount } }\n ` + + `p${i}: pullRequests(baseRefName: $n${i}, states: [OPEN]) { totalCount }`, + ).join("\n "); + return `query($owner: String!, $name: String!${vars}) {\n repository(owner: $owner, name: $name) {\n id\n ${refs}\n }\n}`; +}; + +export async function prune(env, { call, log = console.log }) { + const names = claimed(env); + const deleted = []; + if (names.length === 0) { + log("prune-branches: no branch to delete"); + return { deleted }; + } + const [owner, name] = env.REPOSITORY.split("/"); + let repository; + try { + const variables = { + owner, + name, + ...Object.fromEntries(names.flatMap((branch, i) => [[`b${i}`, `refs/heads/${branch}`], [`n${i}`, branch]])), + }; + repository = (await call("POST", "/graphql", { query: readQuery(names.length), variables }))?.data?.repository; + if (typeof repository?.id !== "string" || repository.id === "") throw new Error("no repository id"); + } catch (error) { + log(`::warning::prune-branches: could not read the branches, so none is deleted: ${error.message}`); + return { deleted }; + } + for (const [i, branch] of names.entries()) { + const ref = repository[`b${i}`]; + const oid = ref?.target?.oid; + // Kept when a pull request from it in any state, or an open one into it, + // exists, or a count does not read. + if (ref?.associatedPullRequests?.totalCount !== 0 || repository[`p${i}`]?.totalCount !== 0 || !SHA.test(oid ?? "")) { + log(`prune-branches: kept ${branch}: ${ref ? "a pull request uses it, or it reads oddly" : "it is gone"}`); + continue; + } + try { + await call("POST", "/graphql", { + query: DELETE_REF, + variables: { repositoryId: repository.id, name: `refs/heads/${branch}`, beforeOid: oid, afterOid: ZERO_OID }, + }); + deleted.push(branch); + log(`prune-branches: deleted ${branch} at ${oid}; restore with gh api repos/${env.REPOSITORY}/git/refs -f ref=refs/heads/${branch} -f sha=${oid}`); + } catch (error) { + log(`::warning::prune-branches: could not delete ${branch}: ${error.message}`); + } + } + return { deleted }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const env = process.env; + try { + const names = claimed(env); + await prune(env, { call: client(env, { dryRun: false, budget: 1 + names.length }) }); + } catch (error) { + console.log(`::warning::prune-branches: ${error.message}`); + } +} diff --git a/.github/scripts/work-resolve-issue/prune-branches.test.mjs b/.github/scripts/work-resolve-issue/prune-branches.test.mjs new file mode 100644 index 00000000000..96a87e97ece --- /dev/null +++ b/.github/scripts/work-resolve-issue/prune-branches.test.mjs @@ -0,0 +1,128 @@ +// prune-branches.mjs: the prune job deletes only a named lane branch of the +// issue that is not this run's, the reuse or the resume branch, and that a +// fresh read shows with no pull request in any state, at the tip it read. +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { DELETE_REF, ZERO_OID } from "../branch-cleanup/cleanup.mjs"; +import { prune } from "./prune-branches.mjs"; + +const lane = (run, attempt = 1) => `work-resolve-issue/issue-77-${run}-${attempt}`; +const SHA = "a".repeat(40); +const ENV = { ISSUE_NUMBER: "77", RUN_ID: "500", REPOSITORY: "o/r", REUSE_BRANCH: "", RESUME_BRANCH: "" }; + +// A fake client: the read answers each aliased ref from `refs` (name -> ref +// node or null); a delete is recorded and answered by `refuse`. +function fake(refs, { readError = null, refuse = () => false, into = {} } = {}) { + const calls = []; + const call = async (method, path, body) => { + calls.push({ method, path, body }); + if (body.query === DELETE_REF) { + if (refuse(body.variables.name)) throw new Error("POST /graphql: refused"); + return { data: { updateRefs: { clientMutationId: null } } }; + } + if (readError) throw new Error(readError); + const repository = { id: "R_1" }; + for (const [key, value] of Object.entries(body.variables)) { + if (/^b\d+$/.test(key)) repository[key] = refs[value.replace(/^refs\/heads\//, "")] ?? null; + if (/^n\d+$/.test(key)) repository[`p${key.slice(1)}`] = { totalCount: into[value] ?? 0 }; + } + return { data: { repository } }; + }; + return { call, calls }; +} +const ref = (count = 0, oid = SHA) => ({ target: { oid }, associatedPullRequests: { totalCount: count } }); +const deletes = (calls) => calls.filter((c) => c.body.query === DELETE_REF).map((c) => c.body.variables); + +test("a named stopped-run branch with no pull request is deleted at the tip read, one request per branch", async () => { + const { call, calls } = fake({ [lane(400)]: ref(), [lane(401)]: ref(0, "b".repeat(40)) }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: `${lane(400)} ${lane(401)}` }, { call, log: () => {} }); + assert.deepEqual(result.deleted, [lane(400), lane(401)]); + assert.equal(calls.filter((c) => c.body.query !== DELETE_REF).length, 1, "one read for every branch"); + assert.deepEqual(deletes(calls), [ + { repositoryId: "R_1", name: `refs/heads/${lane(400)}`, beforeOid: SHA, afterOid: ZERO_OID }, + { repositoryId: "R_1", name: `refs/heads/${lane(401)}`, beforeOid: "b".repeat(40), afterOid: ZERO_OID }, + ]); + const read = calls[0].body; + assert.match(read.query, /associatedPullRequests\(states: \[OPEN, CLOSED, MERGED\]\)/); +}); + +// The run job's runner ran the model, so the list is a claim: the run's own +// branches and the reuse and resume branches are spared even when named. +test("the run's own branch and the reuse and resume branches are never deleted, even when named", async () => { + const names = [lane(500, 1), lane(500, 2), lane(401), lane(402), lane(400)]; + const { call, calls } = fake(Object.fromEntries(names.map((name) => [name, ref()]))); + const result = await prune( + { ...ENV, PRUNE_BRANCHES: names.join(" "), REUSE_BRANCH: lane(401), RESUME_BRANCH: lane(402) }, + { call, log: () => {} }, + ); + assert.deepEqual(result.deleted, [lane(400)]); + assert.deepEqual(deletes(calls).map((d) => d.name), [`refs/heads/${lane(400)}`]); +}); + +test("a name that is not this issue's lane branch is refused before any read", async () => { + for (const name of ["main", "work-resolve-issue/issue-7-400-1", "work-resolve-issue/issue-770-400-1", `${lane(400)}x`, "pr-unblock/issue-77-400-1"]) { + const { call, calls } = fake({ [name]: ref() }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: name }, { call, log: () => {} }); + assert.deepEqual(result.deleted, [], name); + assert.equal(calls.length, 0, name); + } +}); + +test("a branch that backs a pull request, is gone, or reads oddly is kept", async () => { + const { call, calls } = fake({ + [lane(400)]: ref(1), + [lane(401)]: { target: { oid: SHA } }, + [lane(402)]: ref(0, "not a sha"), + [lane(404)]: ref("0"), + }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: [400, 401, 402, 403, 404].map((run) => lane(run)).join(" ") }, { call, log: () => {} }); + assert.deepEqual(result.deleted, []); + assert.deepEqual(deletes(calls), []); +}); + +test("a branch an open pull request targets is kept", async () => { + const { call, calls } = fake({ [lane(400)]: ref(), [lane(401)]: ref() }, { into: { [lane(400)]: 1 } }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: `${lane(400)} ${lane(401)}` }, { call, log: () => {} }); + assert.deepEqual(result.deleted, [lane(401)]); + assert.match(calls[0].body.query, /pullRequests\(baseRefName: \$n0, states: \[OPEN\]\)/); + assert.equal(calls[0].body.variables.n0, lane(400)); +}); + +test("a deleted branch's log line names a restore that works from GitHub's copy", async () => { + const lines = []; + const { call } = fake({ [lane(400)]: ref() }); + await prune({ ...ENV, PRUNE_BRANCHES: lane(400) }, { call, log: (line) => lines.push(line) }); + assert.ok(lines.some((line) => line.includes(`gh api repos/o/r/git/refs -f ref=refs/heads/${lane(400)} -f sha=${SHA}`)), lines.join("\n")); +}); + +test("a failed read deletes nothing", async () => { + const { call, calls } = fake({ [lane(400)]: ref() }, { readError: "POST /graphql: Resource not accessible by integration" }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: lane(400) }, { call, log: () => {} }); + assert.deepEqual(result.deleted, []); + assert.deepEqual(deletes(calls), []); +}); + +test("a refused delete is logged and the next branch still goes", async () => { + const lines = []; + const { call } = fake({ [lane(400)]: ref(), [lane(401)]: ref() }, { refuse: (name) => name.endsWith(lane(400)) }); + const result = await prune({ ...ENV, PRUNE_BRANCHES: `${lane(400)} ${lane(401)}` }, { call, log: (line) => lines.push(line) }); + assert.deepEqual(result.deleted, [lane(401)]); + assert.ok(lines.some((line) => line.startsWith("::warning::") && line.includes(lane(400))), lines.join("\n")); +}); + +test("a repeated name is read and deleted once, and the list is capped", async () => { + const names = Array.from({ length: 25 }, (_, i) => lane(300 + i)); + const { call, calls } = fake(Object.fromEntries(names.map((name) => [name, ref()]))); + const result = await prune({ ...ENV, PRUNE_BRANCHES: [lane(300), ...names].join(" ") }, { call, log: () => {} }); + assert.equal(result.deleted.length, 20); + assert.equal(new Set(deletes(calls).map((d) => d.name)).size, 20); +}); + +test("an environment that is not an issue, run and repository refuses before any call", async () => { + for (const env of [{ ISSUE_NUMBER: "x" }, { RUN_ID: "" }, { REPOSITORY: "o" }]) { + const { call, calls } = fake({}); + await assert.rejects(prune({ ...ENV, PRUNE_BRANCHES: lane(400), ...env }, { call, log: () => {} }), JSON.stringify(env)); + assert.equal(calls.length, 0); + } +}); diff --git a/.github/scripts/work-resolve-issue/report-outcome.mjs b/.github/scripts/work-resolve-issue/report-outcome.mjs index a0fb5b680ac..eeefd5922f4 100755 --- a/.github/scripts/work-resolve-issue/report-outcome.mjs +++ b/.github/scripts/work-resolve-issue/report-outcome.mjs @@ -1275,11 +1275,7 @@ const trustedAuthor = (author, { bot, humans }) => // `at`. A marker naming no run, posted before markers named one, covers every // branch whose tip is not newer than it. export function findPrior({ issueNumber, repo, bot, humans, branches, closing, markers, keepBehind = false }) { - const shape = laneBranch(issueNumber); - const lane = branches - .map((branch) => ({ ...branch, match: shape.exec(branch.name) })) - .filter(({ match }) => match) - .sort((a, b) => Number(b.match[1]) - Number(a.match[1]) || Number(b.match[2]) - Number(a.match[2])); + const lane = laneBranches(branches, issueNumber); const byBot = (pull) => pull?.user?.login === bot; const open = lane.find(({ pulls }) => pulls.some((pull) => pull.state === "open" && byBot(pull))); if (open) { @@ -1289,8 +1285,7 @@ export function findPrior({ issueNumber, repo, bot, humans, branches, closing, m if (trusted) { return { action: "stop", reason: `open PR #${trusted.number} closes the issue` }; } - const reported = ({ match, tipAt }) => - markers.some((marker) => marker.run === match[1] || (marker.run === "" && !(marker.at <= tipAt))); + const reported = reportedIn(markers); const laneTip = ({ tip }) => laneCommit(tip, bot); const usable = (branch) => branch.aheadBy > 0 && laneTip(branch) && !branch.pulls.some(byBot); const reuse = lane.find((branch) => usable(branch) && !reported(branch)); @@ -1324,6 +1319,40 @@ export function findPrior({ issueNumber, repo, bot, humans, branches, closing, m return resume ? { action: "resume", branch: resume.name, tip: resume.sha, cause: decisive.outcome } : { action: "run" }; } +// The issue's lane branches, each with its name's match, newest run and +// attempt first. +const laneBranches = (branches, issueNumber) => { + const shape = laneBranch(issueNumber); + return branches + .map((branch) => ({ ...branch, match: shape.exec(branch.name) })) + .filter(({ match }) => match) + .sort((a, b) => Number(b.match[1]) - Number(a.match[1]) || Number(b.match[2]) - Number(a.match[2])); +}; +// Whether a lanes-App outcome marker reported a lane branch's run. +const reportedIn = (markers) => ({ match, tipAt }) => + markers.some((marker) => marker.run === match[1] || (marker.run === "" && !(marker.at <= tipAt))); + +// At most this many branches are named to prune per run; the rest wait for +// the next run on the issue. +export const PRUNE_CAP = 20; + +// The issue's lane branches a run deletes (owner decision on #7948): a stopped +// run's branch, one whose run posted an outcome marker and whose every commit +// ahead of the default branch is the lanes App's, with no pull request in any state, from the facts findPrior +// decided on, so it costs no read. Spared: the branch the run reuses, resumes +// or starts fresh past (`prior`), an unreported branch (reuse's), and every +// attempt of the run itself (`runId`). A branch someone else pushed to is kept +// for the daily branch cleanup. +export function pruneBranches({ issueNumber, bot, branches, markers, prior, runId = "" }) { + const spared = new Set([prior?.branch, prior?.stale?.branch].filter(Boolean)); + const reported = reportedIn(markers); + return laneBranches(branches, issueNumber) + .filter((branch) => branch.pulls.length === 0 && laneCommit(branch.tip, bot) && allLaneCommits(branch.commits, bot) && reported(branch)) + .filter((branch) => branch.match[1] !== String(runId) && !spared.has(branch.name)) + .map(({ name }) => name) + .slice(0, PRUNE_CAP); +} + // The listed humans' ids in trusted-actors.json; throws when it cannot be read. export function readHumans(path) { const list = JSON.parse(readFileSync(path, "utf8")); @@ -1527,7 +1556,7 @@ export async function closingPulls(github, { owner, name, number }, maxPages = 5 export const staleMarker = (branch) => ``; const staleNoted = (body, branch) => String(body ?? "").split(/\r?\n/, 1)[0] === staleMarker(branch); export const staleComment = (branch) => - `${staleMarker(branch)}\nThis run started fresh from the default branch instead of resuming \`${branch}\`, which is behind it. That branch keeps the earlier run's commits, and no lane deletes it: a person can delete it once it is no longer needed.`; + `${staleMarker(branch)}\nThis run started fresh from the default branch instead of resuming \`${branch}\`, which is behind it. That branch keeps the earlier run's commits until the next run on this issue deletes it, when no pull request uses it.`; // --find-prior: reads the facts findPrior decides on and writes its step // outputs. Any failed read, or an answer of the wrong shape, fails the step, @@ -1598,6 +1627,13 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc const keepBehind = env.FIND_PRIOR_KEEP_BEHIND === "true"; const prior = findPrior({ issueNumber, repo: env.GITHUB_REPOSITORY, bot, humans, branches, closing, markers, keepBehind }); const runs = prior.action === "run" || prior.action === "resume"; + // A stopped run's leftover branches, deleted by work-resolve-issue.yml's + // prune job, which re-checks each; a stop names none. + const prune = + runs || prior.action === "reuse" + ? pruneBranches({ issueNumber, bot, branches, markers, prior, runId: env.GITHUB_RUN_ID ?? "" }) + : []; + const pruned = prune.length ? { "prune-branches": prune.join(" ") } : {}; const outputs = runs ? { proceed: "true", @@ -1616,8 +1652,9 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc "stale-noted": String(appComments.some((comment) => staleNoted(comment.body, prior.stale.branch))), } : {}), + ...pruned, } - : { proceed: "false", "skip-reason": "not-applicable", "reuse-branch": prior.action === "reuse" ? prior.branch : "" }; + : { proceed: "false", "skip-reason": "not-applicable", "reuse-branch": prior.action === "reuse" ? prior.branch : "", ...pruned }; appendFileSync(env.GITHUB_OUTPUT, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join("")); console.log( prior.stale diff --git a/.github/scripts/work-resolve-issue/report-outcome.test.mjs b/.github/scripts/work-resolve-issue/report-outcome.test.mjs index fb2094ff6a1..17000292713 100644 --- a/.github/scripts/work-resolve-issue/report-outcome.test.mjs +++ b/.github/scripts/work-resolve-issue/report-outcome.test.mjs @@ -24,6 +24,7 @@ import { main, mustHold, parseOutcomeMarker, + pruneBranches, heldPaths, readHoldPaths, readFunding, @@ -987,6 +988,8 @@ test("main names lane.json's stale branch on the issue once, and deletes no bran assert.equal(named.length, 1); assert.equal(named[0].body.body.split("\n")[0], ``); assert.ok(named[0].body.body.includes(`\`${stale}\``), named[0].body.body); + assert.doesNotMatch(named[0].body.body, /no lane deletes it/, "the next run on the issue deletes it"); + assert.match(named[0].body.body, /next run on this issue deletes it/); assert.equal(parseOutcomeMarker(named[0].body.body), null, "the stale comment is no outcome marker"); assert.ok(!calls.some((c) => c.path.includes("/git/refs")), "no branch is deleted"); }); @@ -2345,6 +2348,67 @@ test("with no lane branch and no closing PR the run goes ahead as before", () => assert.deepEqual(prior({}), { action: "run" }); }); +// The issue's lane branches a run deletes (#7948): a stopped run's branch, +// one whose run posted an outcome marker and whose tip is the lanes App's, +// with no pull request in any state. The branch the run reuses, resumes or +// starts fresh past, an unreported branch and this run's own are spared. +const prune = (facts) => { + const all = { issueNumber: 77, repo: "o/r", bot: BOT, humans: HUMANS, branches: [], closing: [], markers: [], ...facts }; + return pruneBranches({ ...all, prior: findPrior(all) }); +}; + +test("a stopped run's lane branch with no pull request is pruned", () => { + const markers = [marked("pushback", "400")]; + assert.deepEqual(prune({ branches: [branchFact(lane(400))], markers }), [lane(400)]); + // Every attempt of a stopped run that is not resumed goes. + assert.deepEqual(prune({ branches: [branchFact(lane(400, 1)), branchFact(lane(400, 2))], markers }), [lane(400, 2), lane(400, 1)]); +}); + +test("a lane branch that backs a pull request in any state, by anyone, is kept", () => { + for (const pull of [pullBy(BOT, "closed"), pullBy("stranger", "open"), pullBy("kyle-sexton", "closed")]) { + assert.deepEqual(prune({ branches: [branchFact(lane(400), { pulls: [pull] })], markers: [marked("pushback", "400")] }), [], JSON.stringify(pull)); + } +}); + +test("an unreported lane branch, or one whose tip is not the lanes App's, is kept", () => { + assert.deepEqual(prune({ branches: [branchFact(lane(400))] }), [], "unreported: the reuse branch"); + assert.deepEqual(prune({ branches: [branchFact(lane(401)), branchFact(lane(400))] }), [], "an older unreported branch"); + const tip = { ...LANE_TIP, author: { login: "stranger", type: "User" } }; + assert.deepEqual(prune({ branches: [branchFact(lane(400), { tip })], markers: [marked("pushback", "400")] }), [], "another author's tip"); + const commits = [LANE_TIP, tip]; + assert.deepEqual(prune({ branches: [branchFact(lane(400), { commits })], markers: [marked("pushback", "400")] }), [], "another author's commit"); + assert.deepEqual(prune({ branches: [branchFact(lane(400), { commits: null })], markers: [marked("pushback", "400")] }), [], "a listing cut short"); +}); + +test("the branch the run resumes or starts fresh past is kept, and an older stopped run's goes", () => { + const branches = [branchFact(lane(401), { sha: SHA_400 }), branchFact(lane(400))]; + const markers = [marked("stopped", "400", "2026-10-16T10:30:00Z"), marked("turn-cap", "401")]; + assert.equal(findPrior({ issueNumber: 77, repo: "o/r", bot: BOT, humans: HUMANS, branches, closing: [], markers }).branch, lane(401)); + assert.deepEqual(prune({ branches, markers }), [lane(400)]); + const behind = [branchFact(lane(401), { sha: SHA_400, behindBy: 3 }), branchFact(lane(400))]; + assert.deepEqual(prune({ branches: behind, markers }), [lane(400)], "the stale branch is this run's data"); +}); + +test("no branch of the run itself is pruned, whatever its attempt", () => { + const branches = [branchFact(lane(400, 1)), branchFact(lane(400, 2)), branchFact(lane(399))]; + const markers = [marked("pushback", "399"), marked("pushback", "400")]; + const all = { issueNumber: 77, repo: "o/r", bot: BOT, humans: HUMANS, branches, closing: [], markers }; + assert.deepEqual(pruneBranches({ ...all, prior: findPrior(all), runId: "400" }), [lane(399)]); +}); + +test("--find-prior names a stopped run's branch with no pull request to prune, and none of this run's", async () => { + const comments = [{ user: { login: BOT }, body: "", created_at: "2026-10-16T11:00:00Z" }]; + const comparison = { ahead_by: 1, behind_by: 0, total_commits: 1, commits: [LANE_TIP] }; + const { code, calls, output } = await runPrior({ comments, refs: [refOf(lane(400))], comparison }); + assert.equal(code, 0); + assert.equal(output, `proceed=true\nskip-reason=\nreuse-branch=\nprune-branches=${lane(400)}\n`); + assert.deepEqual(writesToGitHub(calls), [], "the read job deletes nothing"); + const own = await runPrior({ comments, refs: [refOf(lane(400))], comparison, env: { GITHUB_RUN_ID: "400" } }); + assert.equal(own.output, "proceed=true\nskip-reason=\nreuse-branch=\n"); + const kept = await runPrior({ comments, refs: [refOf(lane(400))], comparison, pulls: [pullBy(BOT, "closed")] }); + assert.equal(kept.output, "proceed=true\nskip-reason=\nreuse-branch=\n"); +}); + // findPriorMain against a fake GitHub API: its only effect is the step // outputs, and it writes nothing to GitHub. async function withAsyncDir(files, run) { diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index e415cb55022..4a2e0f5d32f 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -349,6 +349,13 @@ on: Issue mode: the earlier run's lane branch this run resumed; empty otherwise. value: ${{ jobs.issue-fields.outputs.resume-branch }} + scan-prune-branches: + description: >- + Issue mode: the space-separated lane branches of the issue's stopped + runs, with no pull request, that the earlier-work check named for + the caller's prune job to delete; empty otherwise. The prune job + re-checks each. + value: ${{ jobs.issue-fields.outputs.prune-branches }} # From run-claude-cli and the credential steps, never from model text, # but set by the run job, which head code can forge: no caller starts # its fallback call on them. @@ -513,6 +520,7 @@ jobs: reuse-branch: ${{ steps.issue-prior.outputs.reuse-branch }} resume-branch: ${{ steps.issue-prior.outputs.resume-branch }} resume-tip: ${{ steps.issue-prior.outputs.resume-tip }} + prune-branches: ${{ steps.issue-prior.outputs.prune-branches }} # run-claude-cli sets lane-class and handoff after it restores the step # files, so the leg cannot set them; the presence steps run before it. lane-class: ${{ steps.act-key.outputs.lane-class || steps.act-token.outputs.lane-class || steps.act-fallback.outputs.lane-class }} @@ -886,7 +894,10 @@ jobs: # model runs on that branch's tip instead (resume-branch, resume-tip, # resume-cause); a branch behind it starts fresh from the default tip # (stale-branch, stale-tip, and stale-noted when a lanes App comment - # already names it). + # already names it). With the model running or a branch reused, it also + # names the issue's stopped runs' branches with no pull request in any + # state, past the branch it picked (prune-branches), for + # work-resolve-issue.yml's prune job to delete. - name: Check for an earlier run's work on the issue id: issue-prior if: >- @@ -2046,6 +2057,7 @@ jobs: resume-tip: ${{ steps.checked.outputs.resume-tip }} base-sha: ${{ steps.checked.outputs.base-sha }} start-sha: ${{ steps.checked.outputs.start-sha }} + prune-branches: ${{ steps.checked.outputs.prune-branches }} steps: # Each field through env. issue-run-started is set first, alone, so a # refused field still leaves it; claimed says a branch is named. @@ -2061,10 +2073,11 @@ jobs: RESUME_BRANCH: ${{ needs.run.outputs.resume-branch }} RESUME_TIP: ${{ needs.run.outputs.resume-tip }} BASE_SHA: ${{ needs.run.outputs.base-sha }} + PRUNE_BRANCHES: ${{ needs.run.outputs.prune-branches }} with: retries: 0 script: | - const { LANE, ISSUE_NUMBER, STARTED, REUSE_BRANCH, RESUME_BRANCH, RESUME_TIP, BASE_SHA } = process.env; + const { LANE, ISSUE_NUMBER, STARTED, REUSE_BRANCH, RESUME_BRANCH, RESUME_TIP, BASE_SHA, PRUNE_BRANCHES } = process.env; const SHA = /^[0-9a-f]{40}$/; const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const BRANCH = new RegExp(`^${escape(LANE)}/issue-${escape(ISSUE_NUMBER)}-[1-9][0-9]*-[1-9][0-9]*$`); @@ -2083,15 +2096,24 @@ jobs: if (REUSE_BRANCH !== '' && (STARTED !== 'false' || RESUME_BRANCH !== '' || RESUME_TIP !== '')) return bad('reuse-branch'); if ((RESUME_BRANCH === '') !== (RESUME_TIP === '') || (RESUME_TIP !== '' && STARTED !== 'true')) return bad('resume-tip'); if (STARTED === 'true' && BASE_SHA === '') return bad('base-sha'); + // Branches to prune: at most 20 distinct lane branches of the + // issue, none of them the reuse or resume branch. Not re-derived: + // the prune job re-checks each against GitHub before deleting. + const prune = PRUNE_BRANCHES === '' ? [] : PRUNE_BRANCHES.split(' '); + if (prune.length > 20 || new Set(prune).size !== prune.length + || prune.some((name) => !BRANCH.test(name) || name === REUSE_BRANCH || name === RESUME_BRANCH)) return bad('prune-branches'); core.setOutput('claimed', REUSE_BRANCH !== '' || RESUME_BRANCH !== '' ? 'true' : 'false'); core.setOutput('reuse-branch', REUSE_BRANCH); core.setOutput('resume-branch', RESUME_BRANCH); core.setOutput('resume-tip', RESUME_TIP); core.setOutput('base-sha', BASE_SHA); + core.setOutput('prune-branches', PRUNE_BRANCHES); - name: Check out the default branch tip id: base - if: steps.issue.outcome == 'success' && steps.issue.outputs.claimed == 'true' && github.event.repository.default_branch != '' + if: >- + steps.issue.outcome == 'success' && github.event.repository.default_branch != '' + && (steps.issue.outputs.claimed == 'true' || steps.issue.outputs.prune-branches != '') continue-on-error: true uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -2126,7 +2148,10 @@ jobs: GITHUB_OUTPUT=$PRIOR_OUT node .base/.github/scripts/work-resolve-issue/report-outcome.mjs --find-prior # A branch claim must be what the re-derived check chose, field for - # field; with no claim there is nothing to re-derive. + # field; with no claim there is nothing to re-derive. A branch to prune + # is published only when the re-derived check names it too, so a forged + # list deletes nothing the check would spare; a failed re-derive + # publishes none. - name: Publish the checked issue fields id: checked if: >- @@ -2139,16 +2164,22 @@ jobs: RESUME_BRANCH: ${{ steps.issue.outputs.resume-branch }} RESUME_TIP: ${{ steps.issue.outputs.resume-tip }} BASE_SHA: ${{ steps.issue.outputs.base-sha }} + PRUNE_BRANCHES: ${{ steps.issue.outputs.prune-branches }} PRIOR_OUT: ${{ runner.temp }}/issue-prior run: | + prior() { [ ! -f "$PRIOR_OUT" ] || sed -n "s/^$1=//p" "$PRIOR_OUT" | tail -n 1; } if [ "$CLAIMED" = true ]; then - prior() { sed -n "s/^$1=//p" "$PRIOR_OUT" | tail -n 1; } if [ "$(prior reuse-branch)" != "$REUSE_BRANCH" ] || [ "$(prior resume-branch)" != "$RESUME_BRANCH" ] || [ "$(prior resume-tip)" != "$RESUME_TIP" ]; then echo "::error::bad-output: the run job's lane branch is not the one the earlier-work check chooses; nothing is published" exit 1 fi fi + prune="" + derived=" $(prior prune-branches) " + for name in $PRUNE_BRANCHES; do + [[ $derived == *" $name "* ]] && prune="${prune:+$prune }$name" + done { echo "checked=true" echo "reuse-branch=$REUSE_BRANCH" @@ -2156,6 +2187,7 @@ jobs: echo "resume-tip=$RESUME_TIP" echo "base-sha=$BASE_SHA" echo "start-sha=${RESUME_TIP:-$BASE_SHA}" + echo "prune-branches=$prune" } >>"$GITHUB_OUTPUT" # Everything the run job hands on leaves here, on a fresh VM that ran no diff --git a/.github/workflows/work-resolve-issue.yml b/.github/workflows/work-resolve-issue.yml index ef31782d9a9..24ebbf5e56f 100644 --- a/.github/workflows/work-resolve-issue.yml +++ b/.github/workflows/work-resolve-issue.yml @@ -482,6 +482,60 @@ jobs: patch-resume-tip: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-resume-tip || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-resume-tip || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-resume-tip || '' }} reply-attempt: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-reply-attempt || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-reply-attempt || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-reply-attempt || '' }} + # A stopped run's leftover lane branches (owner decision on #7948): the + # issue's lane branches whose run posted an outcome marker and whose commits + # are all the lanes App's, with no pull request in any state, which the read + # runner's earlier-work check named past the branch it reused, resumed or + # started fresh past. The run job that named them ran the model, so the list + # is a claim, held to the lane-branch shape by issue-fields: prune-branches.mjs, + # from the default branch tip, spares this run's own branches and the reuse + # and resume branches, reads every named branch in one GraphQL request, and + # deletes only one with no pull request, at the tip it read. Nothing here + # fails the run. The daily branch cleanup's rule is unchanged. + prune-lane-branches: + needs: [resolve-issue, resolve-issue-subscription, resolve-issue-fallback] + if: >- + ${{ !cancelled() && (needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-prune-branches || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-prune-branches || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-prune-branches || '') != '' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write # delete a lane branch ref + pull-requests: read # each named branch's pull requests + env: + API_BUDGET_TOKEN: github-token + steps: + - name: Check out the default branch tip + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + path: .base + persist-credentials: false + + - name: Set up Node + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 24 + package-manager-cache: false + + - name: Delete the stopped runs' lane branches + env: + GITHUB_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + RUN_ID: ${{ github.run_id }} + PRUNE_BRANCHES: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-prune-branches || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-prune-branches || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-prune-branches || '' }} + REUSE_BRANCH: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-reuse-branch || '' }} + RESUME_BRANCH: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-resume-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-resume-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-resume-branch || '' }} + run: node .base/.github/scripts/work-resolve-issue/prune-branches.mjs + + - name: Log the API budget at the end + if: always() + continue-on-error: true + timeout-minutes: 1 + uses: ./.base/.github/actions/log-api-budget # zizmor: ignore[self-repository] + with: + tokens: github-token + # One `pipeline/` commit status per lane step on the lane branch's # head (post-statuses.mjs). No step is skipped on them; a failing one holds # the merge gate like any failing check. This is the lane's only `statuses: write` grant, on the job's own diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 7c73ac19267..f4f944f13c5 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -3418,6 +3418,10 @@ runs daily and sorts every branch; its rules and tests are in or a lane branch with no pull request whose issue closed. A lane run's stop or resume marker on the issue does not keep the branch once the issue is closed. +While the issue is open, its next lane run deletes a stopped run's lane branch that has no pull +request in any state (owner decision on #7948, +[`pr-run-activity.md`](pr-run-activity.md#issue-mode)); this daily rule is unchanged. + Two jobs run it. The plan job, whose token writes nothing (`contents`, `issues` and `pull-requests` read), sorts the branches, prints how many it will delete, and hands at most `max-deletes-per-run` candidates, in listing order, to the delete job as the run's diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 9860e78530b..04df3cc98a0 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -98,7 +98,7 @@ The read file has five `workflow_call` outputs: `head-sha`, as in the write file [Patch output mode](#patch-output-mode), and `reply-attempt`, the attempt in the name of the `skill-reply---` artifact when the skill ran; the last four are the [scan job](#the-scan-job)'s in both modes. In issue mode it also outputs `scan-base-sha`, -`scan-resume-tip`, `scan-issue-run-started`, `scan-reuse-branch` and `scan-resume-branch`, the run +`scan-resume-tip`, `scan-issue-run-started`, `scan-reuse-branch`, `scan-resume-branch` and `scan-prune-branches`, the run job's pre-model fields as the [issue-fields job](#the-issue-fields-job) checked them ([Issue mode](#issue-mode)). In PR mode it also outputs `lane-class`, `credential-present` and `handed-off`, from the launcher and the credential steps, never from model text ([Two calls on one credential each](#two-calls-on-one-credential-each)), @@ -1627,14 +1627,18 @@ report gate. It checks the run job's pre-model issue fields from `env`, each in 1. `issue-run-started` is `true` or `false`, and is the job's output whatever follows. A reuse or resume branch has the issue's lane-branch shape, and `base-sha` and `resume-tip` are commit ids. The combination is one the run job's steps make: a reuse branch comes with `false` and no - resume; a resume branch comes with its tip and `true`; a started run has a base. + resume; a resume branch comes with its tip and `true`; a started run has a base. The + space-separated `prune-branches` holds at most 20 distinct lane branches of the issue, none of + them the reuse or resume branch. 2. A branch claim is re-derived: `report-outcome.mjs --find-prior`, from the default branch tip with this job's read-only token, must choose the same reuse branch, resume branch and tip, so the branch also meets that check's rules (its commits the lanes App's, not handed back). The run job chose before the model ran, so the re-derivation sets `FIND_PRIOR_KEEP_BEHIND`: a resume branch - the default branch has moved past since then still resumes. + the default branch has moved past since then still resumes. A non-empty `prune-branches` is + re-derived the same way, and only the branches the re-derived check also names are published; + a failed re-derive publishes none. 3. Only then does it set `checked` and the read file's `scan-base-sha`, `scan-resume-tip`, - `scan-reuse-branch` and `scan-resume-branch` outputs, and the start commit (the resume tip, else + `scan-reuse-branch`, `scan-resume-branch` and `scan-prune-branches` outputs, and the start commit (the resume tip, else the base) the scan job applies the patch to. A refusal leaves them empty: the scan job then publishes nothing, and the report job, with `scan-issue-run-started` `true`, reads no outcome and stops the run. @@ -2112,7 +2116,7 @@ that a listed human re-files the work as a new issue. What changes from the PR f comes out as `stale-branch` and `stale-tip` (the tip it read), with `stale-noted` true when a lanes App comment already opens with ``; the runner's `lane.json` carries it as `stale` only while unnoted, and report-outcome then posts that - comment naming it, once per branch, so a person can find and delete it (no lane deletes it): + comment naming it, once per branch; the next run on the issue deletes it (below): `issue-prior` outputs `resume-branch`, `resume-tip` (the tip it read) and `resume-cause`, and the job checks out that tip, so its patch is made there instead of on the default branch tip. Any failed @@ -2297,6 +2301,18 @@ that a listed human re-files the work as a new issue. What changes from the PR f one closes the issue, it opens no PR, logs that PR's number and stops `closed-by-open-pr`, which routes to `retry`, so the next run stops in `issue-prior` while that PR stays open. A failed read fails the job and opens no PR. +- A stopped run's leftover branch is deleted on the issue's next run (owner decision on #7948). + With the model running or a branch reused, `issue-prior` names, as `prune-branches`, every lane + branch of the issue whose run posted a lanes-App outcome marker, whose commits ahead of the + default branch are all the lanes App's, and that has no pull request in any state, sparing the + branch it reuses, resumes or starts fresh past and every branch of this run; at most 20, from + the reads it already made. `work-resolve-issue.yml`'s `prune-lane-branches` job, the lane's only + `contents: write` (with `pull-requests: read`, on its own `GITHUB_TOKEN`), runs + `prune-branches.mjs` from the default branch tip: it drops any name that is not this issue's + lane branch, is this run's own, or is the reuse or resume branch, reads every remaining branch in + one GraphQL request, and deletes only one with no pull request from it and no open one into it, one `updateRefs` per branch whose + `beforeOid` is the tip it read. A failed read deletes nothing, a refused delete is logged, and + neither fails the run. The log line of each delete gives the tip to restore it from. - No check run is posted: there is no PR head. The lane's `report-statuses` job (in `work-resolve-issue.yml`, the lane's only `statuses: write`, on its own `GITHUB_TOKEN`) posts a `pipeline/` commit status on the lane branch's head for `resolve-issue` and