Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .github/scripts/closed-pr-branch-cleanup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
/** Branches that may never be deleted regardless of pull-request state. */
const PROTECTED_BRANCHES = Object.freeze(["main", "dev", "preview", "gh-pages"]);

/** Namespaces reserved for disposable, automation-created branches. */
const DISPOSABLE_BRANCH_PREFIXES = Object.freeze(["codex/", "ingw/"]);

/** Default grace period before a closed PR's head branch becomes eligible. */
const DEFAULT_GRACE_DAYS = 14;

Expand Down Expand Up @@ -53,6 +56,7 @@ function toTimestamp(value) {
*/
const KEEP_REASONS = Object.freeze({
PROTECTED: "protected-branch",
OUTSIDE_DISPOSABLE_NAMESPACE: "outside-disposable-namespace",
MERGED: "pull-request-merged",
OPEN: "open-pull-request",
BASE_OF_OPEN: "base-of-open-pull-request",
Expand All @@ -73,6 +77,8 @@ const KEEP_REASONS = Object.freeze({
* a head is closed and unmerged. One open or merged PR on the same branch
* keeps it, because reopening a PR whose head branch is gone cannot restore
* the commits.
* - Only explicitly disposable automation namespaces are candidates. A pull
* request must not confer authority to delete an arbitrary repository ref.
* - A branch that is the base of an open pull request is kept. Deleting it
* closes the stacked child PR that targets it.
* - Cross-repository (fork) heads are never touched: they live in the
Expand Down Expand Up @@ -147,6 +153,14 @@ function planClosedPrBranchDeletions({
continue;
}

// PR creation/management authority is not repository-ref deletion
// authority. Only namespaces reserved for disposable automation branches
// may enter this privileged cleanup path.
if (!DISPOSABLE_BRANCH_PREFIXES.some((prefix) => branch.startsWith(prefix))) {
keeps.push({ branch, reason: KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE });
continue;
}

const related = byHead.get(branch) || [];
if (related.length === 0) continue; // No PR ever used it; out of scope.

Expand Down Expand Up @@ -219,6 +233,7 @@ function planClosedPrBranchDeletions({

module.exports = {
DEFAULT_GRACE_DAYS,
DISPOSABLE_BRANCH_PREFIXES,
KEEP_REASONS,
PROTECTED_BRANCHES,
isProtectedBranch,
Expand Down
29 changes: 24 additions & 5 deletions .github/scripts/closed-pr-branch-cleanup.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
const NOW = Date.parse("2026-08-26T00:00:00Z");
const DAY = 24 * 60 * 60 * 1000;
const longAgo = new Date(NOW - 60 * DAY).toISOString();
const HEAD_OID = "1111111111111111111111111111111111111111";

function closedPr(overrides) {
return {
Expand All @@ -20,6 +21,7 @@ function closedPr(overrides) {
merged: false,
isCrossRepository: false,
headRefName: "codex/example",
headRefOid: HEAD_OID,
baseRefName: "dev",
closedAt: longAgo,
...overrides,
Expand Down Expand Up @@ -48,7 +50,7 @@ describe("planClosedPrBranchDeletions", () => {
it("deletes a branch whose only pull request closed unmerged past the grace period", () => {
const result = planClosedPrBranchDeletions({
pullRequests: [closedPr({ number: 42, headRefName: "codex/stale" })],
branches: ["codex/stale", "dev"],
branches: [{ name: "codex/stale", oid: HEAD_OID }, "dev"],
now: NOW,
});
assert.deepEqual(deletedBranches(result), ["codex/stale"]);
Expand Down Expand Up @@ -103,13 +105,13 @@ describe("planClosedPrBranchDeletions", () => {
it("never touches a fork head branch", () => {
const result = planClosedPrBranchDeletions({
pullRequests: [
closedPr({ number: 40, headRefName: "patch-1", isCrossRepository: true }),
closedPr({ number: 40, headRefName: "codex/patch-1", isCrossRepository: true }),
],
branches: ["patch-1"],
branches: ["codex/patch-1"],
now: NOW,
});
assert.deepEqual(deletedBranches(result), []);
assert.equal(keepReason(result, "patch-1"), KEEP_REASONS.CROSS_REPOSITORY);
assert.equal(keepReason(result, "codex/patch-1"), KEEP_REASONS.CROSS_REPOSITORY);
});

it("waits out the grace period so a mistaken close can be reopened", () => {
Expand Down Expand Up @@ -147,7 +149,10 @@ describe("planClosedPrBranchDeletions", () => {
it("ignores branches that no pull request ever used", () => {
const result = planClosedPrBranchDeletions({
pullRequests: [closedPr({ number: 80, headRefName: "codex/known" })],
branches: ["codex/known", "codex/never-a-pr"],
branches: [
{ name: "codex/known", oid: HEAD_OID },
{ name: "codex/never-a-pr", oid: HEAD_OID },
],
now: NOW,
});
assert.deepEqual(deletedBranches(result), ["codex/known"]);
Expand All @@ -162,4 +167,18 @@ describe("planClosedPrBranchDeletions", () => {
});
assert.deepEqual(deletedBranches(result), []);
});

it("never lets pull-request state authorize deletion outside disposable namespaces", () => {
const branch = "persistent/release-work";
const result = planClosedPrBranchDeletions({
pullRequests: [closedPr({ number: 100, headRefName: branch })],
branches: [{ name: branch, oid: HEAD_OID }],
now: NOW,
});
assert.deepEqual(deletedBranches(result), []);
assert.equal(
keepReason(result, branch),
KEEP_REASONS.OUTSIDE_DISPOSABLE_NAMESPACE,
);
});
});
Loading