ci: Add automation for creating Release candidate branches (#26327)

This commit is contained in:
Matsu 2026-03-03 11:04:12 +02:00 committed by GitHub
parent 8ac25b8270
commit e1a86eca2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 128 deletions

View File

@ -1,73 +0,0 @@
import semver from 'semver';
import {
ensureEnvVar,
RELEASE_PREFIX,
sh,
stripReleasePrefixes,
trySh,
writeGithubOutput,
} from './github-helpers.mjs';
function remoteBranchExists(branch) {
const res = trySh('git', ['ls-remote', '--heads', 'origin', branch]);
return res.ok && res.out.length > 0;
}
function localRefExists(ref) {
const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]);
return res.ok;
}
function main() {
const rawVersion = ensureEnvVar('VERSION');
const version = stripReleasePrefixes(rawVersion);
if (!semver.valid(version)) {
throw new Error(
`Invalid VERSION. Expected semver like X.Y.Z (optionally prefixed by n8n@). Got: ${rawVersion}`,
);
}
const major = semver.major(version);
const minor = semver.minor(version);
const branch = `release-candidate/${major}.${minor}.x`;
// We create the RC branch from the corresponding release tag
const releaseTag = `${RELEASE_PREFIX}${version}`;
if (remoteBranchExists(branch)) {
console.log(`Branch already exists on origin: ${branch}`);
writeGithubOutput({ branch });
return;
}
// Ensure the tag exists locally (as a tag ref)
// `git rev-parse <tag>^{}` will fail if it doesn't exist.
const tagCommitRes = trySh('git', ['rev-parse', `${releaseTag}^{}`]);
if (!tagCommitRes.ok || !tagCommitRes.out) {
throw new Error(
`Cannot find release tag "${releaseTag}". Make sure the tag exists and has been pushed.`,
);
}
console.log(`Creating branch ${branch} from ${releaseTag} (${tagCommitRes.out})`);
// Create local branch (force safe: it shouldn't exist, but keep it robust)
if (localRefExists(`refs/heads/${branch}`)) {
sh('git', ['branch', '-f', branch, tagCommitRes.out]);
} else {
sh('git', ['switch', '-c', branch, tagCommitRes.out]);
}
sh('git', ['push', 'origin', branch]);
console.log(`Created and pushed: ${branch}`);
writeGithubOutput({ branch });
}
try {
main();
} catch (err) {
console.error(String(err?.message ?? err));
process.exit(1);
}

View File

@ -14,9 +14,9 @@ mock.module('./github-helpers.mjs', {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2' };
if (track === 'beta') return { version: '2.10.1' };
return { version: '1.123.33' };
if (track === 'stable') return { version: '2.9.2', tag: 'n8n@2.9.2' };
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
return { version: '1.123.33', tag: 'n8n@1.123.33' };
},
writeGithubOutput: () => {}, // no-op in tests
},

View File

@ -0,0 +1,156 @@
import semver from 'semver';
import {
getCommitForRef,
localRefExists,
remoteBranchExists,
resolveReleaseTagForTrack,
sh,
writeGithubOutput,
} from './github-helpers.mjs';
const RELEASE_CANDIDATE_BRANCH_PREFIX = 'release-candidate/';
/**
* @typedef BranchChanges
* @property { import('./github-helpers.mjs').TagVersionInfo[] } branchesToEnsure TagVersionInfo for branches the system needs to make sure exist
* @property { string[] } branchesToDeprecate Branches the system needs to remove as deprecated
* */
/**
* Look into git tags and determine which release candidate branches need to
* exist and which need to be deprecated and removed.
*
* @returns { BranchChanges }
* */
export function determineBranchChanges() {
const branchesToDeprecate = [];
const currentBetaVersion = resolveReleaseTagForTrack('beta');
const currentStableVersion = resolveReleaseTagForTrack('stable');
if (!currentBetaVersion || !currentStableVersion) {
throw new Error(
`Could not find current stable and/or beta tags. Beta: ${currentBetaVersion?.tag ?? 'not found'}, Stable: ${currentStableVersion?.tag ?? 'not found'}`,
);
}
const branchesToEnsure = [currentBetaVersion, currentStableVersion];
const stableVersion = currentStableVersion.version;
// Deprecated branch is the current stable minus 2 versions. e.g. stable: 2.9.x, deprecated is 2.7.x
const deprecatedMinorVersion = semver.minor(stableVersion) - 2;
if (deprecatedMinorVersion >= 0) {
const deprecatedBranch = `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(stableVersion)}.${deprecatedMinorVersion}.x`;
branchesToDeprecate.push(deprecatedBranch);
}
return {
branchesToEnsure,
branchesToDeprecate,
};
}
/**
* Takes a TagVersionInfo object and returns a rc-branch name.
*
* e.g. release-candidate/2.8.x
*
* @param {import('./github-helpers.mjs').TagVersionInfo} tagVersionInfo
*
* @returns { `${RELEASE_CANDIDATE_BRANCH_PREFIX}${number}.${number}.x` }
* */
export function tagVersionInfoToReleaseCandidateBranchName(tagVersionInfo) {
const version = tagVersionInfo.version;
return `${RELEASE_CANDIDATE_BRANCH_PREFIX}${semver.major(version)}.${semver.minor(version)}.x`;
}
/**
* @param {import("./github-helpers.mjs").TagVersionInfo} tagInfo
*/
function ensureBranch(tagInfo) {
const branch = tagVersionInfoToReleaseCandidateBranchName(tagInfo);
if (remoteBranchExists(branch)) {
console.log(`Branch ${branch} already exists on origin. Skipping.`);
return branch;
}
const commitRef = getCommitForRef(tagInfo.tag);
console.log(`Creating branch ${branch} from ${tagInfo.tag} (${commitRef})`);
// Create local branch (force safe: it shouldn't exist, but keep it robust)
if (localRefExists(`refs/heads/${branch}`)) {
sh('git', ['branch', '-f', branch, commitRef]);
} else {
sh('git', ['switch', '-c', branch, commitRef]);
}
sh('git', ['push', 'origin', branch]);
return branch;
}
/**
* @param {string} branch
*/
function removeBranch(branch) {
if (!remoteBranchExists(branch)) {
console.log(`Couldn't find branch ${branch}. Skipping removal.`);
return null;
}
console.log(`Removing remote branch ${branch} from origin...`);
// Delete remote branch
sh('git', ['push', 'origin', '--delete', branch]);
// Optional local cleanup (keeps reruns tidy)
if (localRefExists(`refs/heads/${branch}`)) {
console.log(`Removing local branch ${branch}...`);
sh('git', ['branch', '-D', branch]);
}
return branch;
}
function main() {
const branchChanges = determineBranchChanges();
console.log('💡 Determined branch changes');
console.log('');
console.log(
` Branches to ensure: ${branchChanges.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName).join(', ')}`,
);
console.log(` Branches to deprecate: ${branchChanges.branchesToDeprecate.join(', ')}`);
console.log('');
console.log('Preparing to apply changes...');
let ensuredBranches = [];
for (const tagInfo of branchChanges.branchesToEnsure) {
const branch = ensureBranch(tagInfo);
ensuredBranches.push(branch);
}
console.log('');
console.log('Starting deprecation of branches...');
let removedBranches = [];
for (const branch of branchChanges.branchesToDeprecate) {
const removedBranch = removeBranch(branch);
if (removedBranch) {
removedBranches.push(removedBranch);
}
}
console.log('Done!');
writeGithubOutput({
ensuredBranches: ensuredBranches.join(','),
removedBranches: removedBranches.join(','),
});
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}

View File

@ -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/ensure-release-candidate-branches.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: {
RELEASE_TRACKS: ['stable', 'beta', 'v1'],
RELEASE_PREFIX: 'n8n@',
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2', tag: 'n8n@2.9.2' };
if (track === 'beta') return { version: '2.10.1', tag: 'n8n@2.10.1' };
return { version: '1.123.33', tag: 'n8n@1.123.33' };
},
writeGithubOutput: () => {}, // no-op in tests
sh: () => {}, // no-op in tests
getCommitForRef: () => {}, // no-op in tests
remoteBranchExists: () => {}, // no-op in tests
localRefExists: () => {}, // no-op in tests
},
});
let determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName;
before(async () => {
({ determineBranchChanges, tagVersionInfoToReleaseCandidateBranchName } = await import(
'./ensure-release-candidate-branches.mjs'
));
});
describe('Determine branch changes', () => {
it('Correctly determines ensureable branches', () => {
const output = determineBranchChanges();
const ensureBranches = output.branchesToEnsure.map(tagVersionInfoToReleaseCandidateBranchName);
assert.ok(
ensureBranches.includes('release-candidate/2.10.x'),
"Beta release-candidate branch doesn't exist",
);
assert.ok(
ensureBranches.includes('release-candidate/2.9.x'),
"Stable release-candidate branch doesn't exist",
);
});
it('Correctly determines deprecated branches', () => {
/** @type { import('./ensure-release-candidate-branches.mjs').BranchChanges} */
const output = determineBranchChanges();
assert.ok(
output.branchesToDeprecate.includes('release-candidate/2.7.x'),
'Existing branch release-candidate/2.7.x should be marked for removal',
);
});
});

View File

@ -231,3 +231,19 @@ export function listTagsPointingAt(commit) {
.map((s) => s.trim())
.filter(Boolean);
}
/**
* @param {string} branch
*/
export function remoteBranchExists(branch) {
const res = trySh('git', ['ls-remote', '--heads', 'origin', branch]);
return res.ok && res.out.length > 0;
}
/**
* @param {string} ref
*/
export function localRefExists(ref) {
const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]);
return res.ok;
}

View File

@ -1,52 +0,0 @@
name: Ensure release-candidate branch
on:
workflow_dispatch:
inputs:
version:
description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)'
required: true
type: string
workflow_call:
inputs:
version:
description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)'
required: true
type: string
outputs:
branch:
description: 'release-candidate branch name (e.g. release-candidate/2.8.x)'
value: ${{ jobs.ensure.outputs.branch }}
jobs:
ensure:
runs-on: ubuntu-slim
permissions:
contents: write
outputs:
branch: ${{ steps.rc.outputs.branch }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Ensure release-candidate branch exists
id: rc
env:
VERSION: ${{ inputs.version }}
run: node ./.github/scripts/create-release-candidate-branch.mjs
- name: Print branch
run: |
echo "Release-candidate branch: ${{ steps.rc.outputs.branch }}"

View File

@ -42,3 +42,25 @@ jobs:
with:
version: ${{ inputs.new_stable_version }}
release-channel: stable
ensure-release-candidate-branches:
name: Ensure release-candidate branches
if: inputs.release_type != 'rc'
runs-on: ubuntu-slim
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
fetch-tags: true
- name: Setup NodeJS
uses: ./.github/actions/setup-nodejs
with:
install-command: npm install --prefix=.github/scripts --no-package-lock
build-command: ''
- name: Ensure release-candidate branches
run: node ./.github/scripts/ensure-release-candidate-branches.mjs