From e14b9680ff9efb5c393f119c55cbb59085d54319 Mon Sep 17 00:00:00 2001 From: Matsu Date: Mon, 2 Mar 2026 13:58:48 +0200 Subject: [PATCH] ci: Clean up abandoned release branches on PR dismiss (#26342) --- .github/scripts/cleanup-release-branch.mjs | 123 +++++++++++++++ .../scripts/cleanup-release-branch.test.mjs | 147 ++++++++++++++++++ .github/scripts/github-helpers.mjs | 18 ++- .github/scripts/package.json | 3 +- ...til-cleanup-abandoned-release-branches.yml | 40 +++++ 5 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/cleanup-release-branch.mjs create mode 100644 .github/scripts/cleanup-release-branch.test.mjs create mode 100644 .github/workflows/util-cleanup-abandoned-release-branches.yml diff --git a/.github/scripts/cleanup-release-branch.mjs b/.github/scripts/cleanup-release-branch.mjs new file mode 100644 index 00000000000..4de1bbceb68 --- /dev/null +++ b/.github/scripts/cleanup-release-branch.mjs @@ -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/ + // head: release-pr/ + 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/" + 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(); +} diff --git a/.github/scripts/cleanup-release-branch.test.mjs b/.github/scripts/cleanup-release-branch.test.mjs new file mode 100644 index 00000000000..d74805114a7 --- /dev/null +++ b/.github/scripts/cleanup-release-branch.test.mjs @@ -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(', ')})`, + ); + }); +}); diff --git a/.github/scripts/github-helpers.mjs b/.github/scripts/github-helpers.mjs index 64e7c493055..78c989a043a 100644 --- a/.github/scripts/github-helpers.mjs +++ b/.github/scripts/github-helpers.mjs @@ -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); } diff --git a/.github/scripts/package.json b/.github/scripts/package.json index 46faec2df61..9b97024d79b 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -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" } } diff --git a/.github/workflows/util-cleanup-abandoned-release-branches.yml b/.github/workflows/util-cleanup-abandoned-release-branches.yml new file mode 100644 index 00000000000..d2caac7dd5c --- /dev/null +++ b/.github/workflows/util-cleanup-abandoned-release-branches.yml @@ -0,0 +1,40 @@ +name: 'Util: Cleanup abandoned release branches' + +on: + pull_request: + types: [closed] + +jobs: + delete-release-branch: + # Only if PR was closed without merge + if: > + github.event.pull_request.merged == false + runs-on: ubuntu-slim + permissions: + contents: write + + steps: + - name: Generate GitHub App Token + id: generate_token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }} + private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }} + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: ./.github/actions/setup-nodejs + with: + build-command: '' + install-command: npm install --prefix=.github/scripts --no-package-lock + + - name: Cleanup release branch if PR qualifies + run: node .github/scripts/cleanup-release-branch.mjs + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + GITHUB_REPOSITORY: ${{ github.repository }}