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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 79 additions & 7 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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.
Expand All @@ -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 });
Expand All @@ -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);
Expand Down Expand Up @@ -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"],
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
97 changes: 97 additions & 0 deletions .github/scripts/work-resolve-issue/prune-branches.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
Loading
Loading