mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
ci: Add automation for creating Release candidate branches (#26327)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user