ci: Introduce backport workflow and compute-backport-targets script (#26518)

This commit is contained in:
Matsu
2026-03-04 11:15:09 +00:00
committed by GitHub
parent d48b7986a5
commit 0a17291939
5 changed files with 195 additions and 35 deletions
@@ -0,0 +1,74 @@
// Creates backport PR's according to labels on merged PR
import { readPrLabels, resolveRcBranchForTrack, writeGithubOutput } from './github-helpers.mjs';
/** @type { Record<string, import('./github-helpers.mjs').ReleaseTrack> } */
const BACKPORT_BY_TAG_MAP = {
'Backport to Beta': 'beta',
'Backport to Stable': 'stable',
};
const BACKPORT_BY_BRANCH_MAP = {
'Backport to v1': '1.x',
};
/**
* @param {Set<string>} labels
*
* @returns { Set<string> }
*/
export function labelsToReleaseCandidateBranches(labels) {
const targets = new Set();
// Backport by tag map includes mapping of label to git tag to resolve
for (const [label, tag] of Object.entries(BACKPORT_BY_TAG_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
const branch = resolveRcBranchForTrack(tag);
// Make sure our backport branch exists
if (!branch) {
continue;
}
targets.add(branch);
}
// Backport by branch map includes mapping of label to git branch. This is used for
// older versions of n8n. v1, etc.
for (const [label, branch] of Object.entries(BACKPORT_BY_BRANCH_MAP)) {
// Check if backport label is present
if (!labels.has(label)) {
continue;
}
targets.add(branch);
}
return targets;
}
async function main() {
const labels = new Set(readPrLabels());
if (!labels || labels.size === 0) {
console.log('No labels on PR. Exiting...');
return;
}
const backportBranches = labelsToReleaseCandidateBranches(labels);
if (backportBranches.size === 0) {
console.log('No backports needed. Exiting...');
return;
}
const target_branches = [...backportBranches].join(' '); // korthout/backport-action@v4 uses space-delimited branch list
writeGithubOutput({ target_branches });
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,62 @@
import { describe, it, mock, before } from 'node:test';
import assert from 'node:assert/strict';
/**
* Run these tests by running
*
* node --test --experimental-test-module-mocks ./.github/scripts/compute-backport-targets.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: () => {}, // no-op
resolveRcBranchForTrack: mockResolveRcBranchForTrack,
writeGithubOutput: () => {}, //no-op
},
});
function mockResolveRcBranchForTrack(track) {
switch (track) {
case 'beta':
return 'release-candidate/2.10.1';
case 'stable':
return 'release-candidate/2.9.4';
}
return undefined;
}
let labelsToReleaseCandidateBranches;
before(async () => {
({ labelsToReleaseCandidateBranches } = await import('./compute-backport-targets.mjs'));
});
describe('Compute backport targets', () => {
it('Finds backport branches for pointer tag labels', () => {
const labels = new Set(['Backport to Beta', 'Backport to Stable']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 2);
assert.ok(result.has('release-candidate/2.10.1'));
assert.ok(result.has('release-candidate/2.9.4'));
});
it("Doesn't parse other labes to backport branches", () => {
const labels = new Set(['n8n team', 'release']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
it("Doesn't parse malformed backport labels", () => {
const labels = new Set(['Backport to Fork', 'Backport to my Home']);
/** @type { Set<string> } */
const result = labelsToReleaseCandidateBranches(labels);
assert.equal(result.size, 0);
});
});