ci: Clean up abandoned release branches on PR dismiss (#26342)

This commit is contained in:
Matsu
2026-03-02 11:58:48 +00:00
committed by GitHub
parent 4dcc2d8806
commit e14b9680ff
5 changed files with 323 additions and 8 deletions
+123
View File
@@ -0,0 +1,123 @@
import fs from 'node:fs/promises';
import { getOctokit } from '@actions/github';
import { ensureEnvVar, readPrLabels } from './github-helpers.mjs';
/**
* @typedef {PullRequestCheckPass | PullRequestCheckFail} PullRequestCheckResult
**/
/**
* @typedef PullRequestCheckPass
* @property {true} pass
* @property {string} baseRef
* */
/**
* @typedef PullRequestCheckFail
* @property {false} pass
* @property {string} reason
* */
/**
* @param {PullRequestCheckResult} pullRequestCheck
*
* @returns { pullRequestCheck is PullRequestCheckFail }
* */
function pullRequestCheckFailed(pullRequestCheck) {
return !pullRequestCheck.pass;
}
/**
* @param {any} pullRequest
* @returns {PullRequestCheckResult}
*/
export function pullRequestIsDismissedRelease(pullRequest) {
if (!pullRequest) {
throw new Error('Missing pullRequest in event payload');
}
const baseRef = pullRequest?.base?.ref ?? '';
const headRef = pullRequest?.head?.ref ?? '';
const merged = Boolean(pullRequest?.merged);
if (merged) {
return { pass: false, reason: 'PR was merged' };
}
// Must match your release PR pattern:
// base: release/<ver>
// head: release-pr/<ver>
if (!baseRef.startsWith('release/')) {
return { pass: false, reason: `Base ref '${baseRef}' is not release/*` };
}
if (!headRef.startsWith('release-pr/')) {
return { pass: false, reason: `Head ref '${headRef}' is not release-pr/*` };
}
const baseVer = baseRef.slice('release/'.length);
const headVer = headRef.slice('release-pr/'.length);
if (!baseVer || baseVer !== headVer) {
return { pass: false, reason: `Version mismatch: base='${baseVer}' head='${headVer}'` };
}
const labelNames = readPrLabels(pullRequest);
if (!labelNames.includes('release')) {
return {
pass: false,
reason: `Missing required label 'release' (labels: ${labelNames.join(', ') || '[none]'})`,
};
}
return { pass: true, baseRef };
}
async function main() {
const token = ensureEnvVar('GITHUB_TOKEN');
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const repoFullName = ensureEnvVar('GITHUB_REPOSITORY');
const [owner, repo] = repoFullName.split('/');
if (!owner || !repo) {
throw new Error(`Invalid GITHUB_REPOSITORY: '${repoFullName}'`);
}
const rawEventData = await fs.readFile(eventPath, 'utf8');
const event = JSON.parse(rawEventData);
const result = pullRequestIsDismissedRelease(event.pull_request);
if (pullRequestCheckFailed(result)) {
console.log(`no-op: ${result.reason}`);
return;
}
const branch = result.baseRef; // e.g. "release/2.11.0"
console.log(`PR qualifies. Deleting branch '${branch}'...`);
const octokit = getOctokit(token);
try {
await octokit.rest.git.deleteRef({
owner,
repo,
// ref must be "heads/<branch>"
ref: `heads/${branch}`,
});
console.log(`Deleted '${branch}'.`);
} catch (err) {
// If it was already deleted, treat as success.
const status = err?.status;
if (status === 404) {
console.log(`Branch '${branch}' not found (already deleted).`);
return;
}
console.error(err);
throw new Error(`Failed to delete '${branch}'.`);
}
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,147 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
import { readPrLabels } from './github-helpers.mjs';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/cleanup-release-branch.test.mjs
* */
// mock.module must be called before the module under test is imported,
// because static imports are hoisted and resolve before any code runs.
mock.module('./github-helpers.mjs', {
namedExports: {
ensureEnvVar: () => {}, // no-op
readPrLabels: (pr) => {
return readPrLabels(pr);
},
},
});
let pullRequestIsDismissedRelease;
before(async () => {
({ pullRequestIsDismissedRelease } = await import('./cleanup-release-branch.mjs'));
});
describe('pullRequestIsDismissedRelease', () => {
it('Recognizes classic dismissed pull request', () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, true);
assert.equal(result.reason, undefined);
});
it("Doesn't pass PR with malformed head", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'my-fork-release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Head ref '${pullRequest.head.ref}' is not release-pr/*`);
});
it("Doesn't pass PR with malformed base", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'master',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `Base ref '${pullRequest.base.ref}' is not release/*`);
});
it("Doesn't pass merged PR's", () => {
const pullRequest = {
merged: true,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(result.reason, `PR was merged`);
});
it("Doesn't pass on PR version mismatch", () => {
const pullRequest = {
merged: false,
labels: ['release'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.1',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Version mismatch: base='${pullRequest.base.ref.replace('release/', '')}' head='${pullRequest.head.ref.replace('release-pr/', '')}'`,
);
});
it("Doesn't pass a PR with missing 'release' label", () => {
const pullRequest = {
merged: false,
labels: ['release-pr', 'core-team'],
base: {
ref: 'release/2.9.0',
},
head: {
ref: 'release-pr/2.9.0',
},
};
/** @type { import('./cleanup-release-branch.mjs').PullRequestCheckResult } */
const result = pullRequestIsDismissedRelease(pullRequest);
assert.equal(result.pass, false);
assert.equal(
result.reason,
`Missing required label 'release' (labels: ${pullRequest.labels.join(', ')})`,
);
});
});
+11 -7
View File
@@ -118,14 +118,18 @@ export function stripReleasePrefixes(tag) {
}
/**
* @returns { string[] }
* */
export function readPrLabels() {
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
* @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH
*
* @returns {string[]}
*/
export function readPrLabels(pullRequest) {
if (!pullRequest) {
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
pullRequest = event.pull_request;
}
/** @type { string[] | { name: string }[] } */
const labels = event?.pull_request?.labels ?? [];
const labels = pullRequest?.labels ?? [];
return labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
}
+2 -1
View File
@@ -7,6 +7,7 @@
"p-limit": "3.1.0",
"picocolors": "1.0.1",
"semver": "7.5.4",
"tempfile": "5.0.0"
"tempfile": "5.0.0",
"@actions/github": "9.0.0"
}
}