ci: Clarify and automate release process (#26181)

This commit is contained in:
Matsu
2026-02-26 07:45:31 +00:00
committed by GitHub
parent 6ec3255397
commit 96e446fc61
8 changed files with 415 additions and 41 deletions
+122
View File
@@ -0,0 +1,122 @@
import { readFileSync } from 'node:fs';
import { RELEASE_TRACKS, resolveReleaseTagForTrack, writeGithubOutput } from './github-helpers.mjs';
import semver from 'semver';
/**
* @param {any} packageVersion
*/
export function determineTrack(packageVersion) {
if (!semver.valid(packageVersion)) {
throw new Error(`Package semver not valid. Got ${packageVersion}`);
}
/** @type { Partial<Record<import('./github-helpers.mjs').ReleaseTrack, import('./github-helpers.mjs').TagVersionInfo>> } */
const trackToReleaseMap = {};
for (const t of RELEASE_TRACKS) {
trackToReleaseMap[t] = resolveReleaseTagForTrack(t);
}
console.log('Current Tracks: ', JSON.stringify(trackToReleaseMap, null, 4));
let track = null;
let newStable = null;
let bump = determineBump(packageVersion);
const releaseType = determineReleaseType(packageVersion);
// Check through our current release versions, if semver matches,
// we inherit the track pointer from them
for (const [releaseTrack, tagVersionInfo] of Object.entries(trackToReleaseMap)) {
if (tagVersionInfo && matchesTrack(tagVersionInfo, packageVersion)) {
track = releaseTrack;
break;
}
}
if (!track) {
if (!trackToReleaseMap.beta?.version) {
throw new Error(
'Likely updating to new beta release, but no existing beta tag was found in git.',
);
}
// If not track was found in current versions, we verify we're building a
// new beta version and the input is not invalid.
assertNewBetaRelease(trackToReleaseMap.beta.version, packageVersion);
track = 'beta';
newStable = trackToReleaseMap.beta.version;
}
if (!track) {
throw new Error('Could not determine track for release. Exiting...');
}
const output = {
version: packageVersion,
track,
bump,
new_stable_version: newStable,
release_type: releaseType,
};
writeGithubOutput(output);
console.log(
`Determined track info: track=${track}, version=${packageVersion}, new_stable_version=${newStable}, release_type=${releaseType}`,
);
return output;
}
/**
* The current version matches the track, if their Major and Minor semvers match.
*
* This means that we are working with a patch release
*
* @param {import("./github-helpers.mjs").TagVersionInfo} tagVersionInfo
* @param {any} currentVersion
*/
function matchesTrack(tagVersionInfo, currentVersion) {
if (semver.major(tagVersionInfo.version) !== semver.major(currentVersion)) {
return false;
}
if (semver.minor(tagVersionInfo.version) !== semver.minor(currentVersion)) {
return false;
}
return true;
}
/**
* @param {string} currentBetaVersion
* @param {any} currentVersion
*/
function assertNewBetaRelease(currentBetaVersion, currentVersion) {
if (semver.major(currentBetaVersion) !== semver.major(currentVersion)) {
throw new Error('Major version bumps are not allowed by this pipeline');
}
const bumpedCurrentBeta = semver.inc(currentBetaVersion, 'minor');
if (semver.minor(bumpedCurrentBeta) !== semver.minor(currentVersion)) {
throw new Error(
`Trying to upgrade minor version by more than one increment. Previous: ${bumpedCurrentBeta}, Requested: ${currentVersion}`,
);
}
}
function determineReleaseType(currentVersion) {
if (currentVersion.includes('-rc.')) {
return 'rc';
}
return 'stable';
}
function determineBump(currentVersion) {
if (semver.patch(currentVersion) === 0 && determineReleaseType(currentVersion) != 'rc') {
return 'minor';
}
return 'patch';
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
determineTrack(packageJson.version);
}
@@ -0,0 +1,91 @@
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/determine-version-info.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'],
resolveReleaseTagForTrack: (track) => {
// Always return deterministic data
if (track === 'stable') return { version: '2.9.2' };
if (track === 'beta') return { version: '2.10.0' };
return { version: '1.123.33' };
},
writeGithubOutput: () => {}, // no-op in tests
},
});
let determineTrack;
before(async () => {
({ determineTrack } = await import('./determine-version-info.mjs'));
});
describe('determine-tracks', () => {
it('Allow patch releases on stable', () => {
const output = determineTrack('2.9.3');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.3');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
it('Allow patch releases on beta', () => {
const output = determineTrack('2.10.1');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.1');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
// This use case might happen if a patch release fails and we proceed with rolling over to next release
it('Allow skipping versions in patches', () => {
const output = determineTrack('2.9.4');
assert.equal(output.track, 'stable');
assert.equal(output.version, '2.9.4');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'stable');
});
it('Disallow skipping versions in minors', () => {
assert.throws(() => determineTrack('2.12.0'));
});
it('Disallow changing major version', () => {
assert.throws(() => determineTrack('3.0.0'));
});
it('Throw when track is not determinable', () => {
assert.throws(() => determineTrack(''));
});
it('Set track as "beta" when doing a minor bump', () => {
const output = determineTrack('2.11.0');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.11.0');
assert.equal(output.bump, 'minor');
assert.equal(output.new_stable_version, '2.10.0');
assert.equal(output.release_type, 'stable');
});
it('Set release_type accordingly on rc releases', () => {
const output = determineTrack('2.10.1-rc.1');
assert.equal(output.track, 'beta');
assert.equal(output.version, '2.10.1-rc.1');
assert.equal(output.bump, 'patch');
assert.equal(output.new_stable_version, null);
assert.equal(output.release_type, 'rc');
});
});
+50 -4
View File
@@ -9,6 +9,22 @@ export const RELEASE_TRACKS = /** @type { const } */ ([
'v1',
]);
/**
* @typedef {typeof RELEASE_TRACKS[number]} ReleaseTrack
* */
/**
* @typedef {`${number}.${number}.${number}`} SemVer
* */
/**
* @typedef {`${RELEASE_PREFIX}${SemVer}`} ReleaseVersion
* */
/**
* @typedef {{ tag: ReleaseVersion, version: SemVer}} TagVersionInfo
* */
export const RELEASE_PREFIX = 'n8n@';
/**
@@ -16,6 +32,8 @@ export const RELEASE_PREFIX = 'n8n@';
* Returns the *tag string* (e.g. "n8n@2.7.0") or null.
*
* @param {string[]} tags
*
* @returns { ReleaseVersion | null }
* */
export function pickHighestReleaseTag(tags) {
const versions = tags
@@ -24,13 +42,13 @@ export function pickHighestReleaseTag(tags) {
.filter(({ v }) => semver.valid(v))
.sort((a, b) => semver.rcompare(a.v, b.v));
return versions[0]?.tag ?? null;
return /** @type { ReleaseVersion } */ (versions[0]?.tag) ?? null;
}
/**
* @param {string} track
* @param {any} track
*
* @returns { typeof RELEASE_TRACKS[number] }
* @returns { ReleaseTrack }
* */
export function ensureReleaseTrack(track) {
if (!RELEASE_TRACKS.includes(track)) {
@@ -40,6 +58,30 @@ export function ensureReleaseTrack(track) {
return track;
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* n8n@x.y.z tag pointing at the same commit.
*
* Returns null if the track tag or release tag is missing.
*
* @param { typeof RELEASE_TRACKS[number] } track
*
* @returns { TagVersionInfo }
* */
export function resolveReleaseTagForTrack(track) {
const commit = getCommitForRef(track);
if (!commit) return null;
const tagsAtCommit = listTagsPointingAt(commit);
const releaseTag = pickHighestReleaseTag(tagsAtCommit);
if (!releaseTag) return null;
return {
tag: releaseTag,
version: stripReleasePrefixes(releaseTag),
};
}
/**
* Resolve a release track tag (stable/beta/etc.) to the corresponding
* release-candidate/<major>.<minor>.x branch, based on the n8n@<x.y.z> tag
@@ -66,9 +108,13 @@ export function resolveRcBranchForTrack(track) {
/**
* @param {string} tag
*
* @returns { SemVer }
* */
export function stripReleasePrefixes(tag) {
return tag.startsWith(RELEASE_PREFIX) ? tag.slice(RELEASE_PREFIX.length) : tag;
return /** @type { SemVer } */ (
tag.startsWith(RELEASE_PREFIX) ? tag.slice(RELEASE_PREFIX.length) : tag
);
}
/**
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"checkJs": true,
"moduleResolution": "node"
},
"exclude": ["node_modules"]
}