mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
ci: Clean up abandoned release branches on PR dismiss (#26342)
This commit is contained in:
parent
4dcc2d8806
commit
e14b9680ff
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal file
123
.github/scripts/cleanup-release-branch.mjs
vendored
Normal 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();
|
||||||
|
}
|
||||||
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal file
147
.github/scripts/cleanup-release-branch.test.mjs
vendored
Normal file
|
|
@ -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(', ')})`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
.github/scripts/github-helpers.mjs
vendored
18
.github/scripts/github-helpers.mjs
vendored
|
|
@ -118,14 +118,18 @@ export function stripReleasePrefixes(tag) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns { string[] }
|
* @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH
|
||||||
* */
|
*
|
||||||
export function readPrLabels() {
|
* @returns {string[]}
|
||||||
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
|
*/
|
||||||
|
export function readPrLabels(pullRequest) {
|
||||||
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
if (!pullRequest) {
|
||||||
|
const eventPath = ensureEnvVar('GITHUB_EVENT_PATH');
|
||||||
|
const event = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
|
||||||
|
pullRequest = event.pull_request;
|
||||||
|
}
|
||||||
/** @type { string[] | { name: string }[] } */
|
/** @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);
|
return labels.map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
.github/scripts/package.json
vendored
3
.github/scripts/package.json
vendored
|
|
@ -7,6 +7,7 @@
|
||||||
"p-limit": "3.1.0",
|
"p-limit": "3.1.0",
|
||||||
"picocolors": "1.0.1",
|
"picocolors": "1.0.1",
|
||||||
"semver": "7.5.4",
|
"semver": "7.5.4",
|
||||||
"tempfile": "5.0.0"
|
"tempfile": "5.0.0",
|
||||||
|
"@actions/github": "9.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
.github/workflows/util-cleanup-abandoned-release-branches.yml
vendored
Normal file
40
.github/workflows/util-cleanup-abandoned-release-branches.yml
vendored
Normal file
|
|
@ -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 }}
|
||||||
Loading…
Reference in New Issue
Block a user