ci: Support easy experimental releases (#30947)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Matsu
2026-05-22 12:21:53 +03:00
committed by GitHub
parent 1024e76de7
commit 9a7f44ea64
4 changed files with 119 additions and 32 deletions
+13 -15
View File
@@ -10,22 +10,15 @@ const exec = promisify(child_process.exec);
/**
* @param {string | semver.SemVer} currentVersion
* @param {string} sha first 8 chars of the commit SHA being released
*/
export function generateExperimentalVersion(currentVersion) {
export function generateExperimentalVersion(currentVersion, sha) {
const parsed = semver.parse(currentVersion);
if (!parsed) throw new Error(`Invalid version: ${currentVersion}`);
if (!sha) throw new Error('sha is required to generate an experimental version');
// Check if it's already an experimental version
if (parsed.prerelease.length > 0 && parsed.prerelease[0] === 'exp') {
const minor = parsed.prerelease[1] || 0;
const minorInt = typeof minor === 'string' ? parseInt(minor) : minor;
// Increment the experimental minor version
const expMinor = minorInt + 1;
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.${expMinor}`;
}
// Create new experimental version: <major>.<minor>.<patch>-exp.0
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.0`;
// Prefix the exp sha with a g to prevent invalid semver from a leading 0 (e.g. -exp.01234567)
return `${parsed.major}.${parsed.minor}.${parsed.patch}-exp.g${sha}`;
}
/**
@@ -155,12 +148,13 @@ export function propagateDirtyTransitively(packageMap, depsByPackage) {
/**
* @param {string} version
* @param {import('semver').ReleaseType | 'experimental'} releaseType
* @param {string} [sha] required when releaseType is 'experimental'
* @returns {string}
*/
export function computeNewVersion(version, releaseType) {
export function computeNewVersion(version, releaseType, sha) {
switch (releaseType) {
case 'experimental':
return generateExperimentalVersion(version);
return generateExperimentalVersion(version, /** @type {string} */ (sha));
case 'premajor':
return /** @type {string} */ (
semver.inc(
@@ -186,6 +180,10 @@ async function bumpVersions() {
// TODO: if releaseType is `auto` determine release type based on the changelog
const lastTag = (await exec('git describe --tags --match "n8n@*" --abbrev=0')).stdout.trim();
const sha =
releaseType === 'experimental'
? (await exec('git rev-parse --short=8 HEAD')).stdout.trim()
: undefined;
const packages = JSON.parse(
(
await exec(
@@ -277,7 +275,7 @@ async function bumpVersions() {
let newVersion = version;
if (isDirty || dependencyIsDirty) {
newVersion = computeNewVersion(version, releaseType);
newVersion = computeNewVersion(version, releaseType, sha);
}
packageJson.version = packageMap[packageName].nextVersion = newVersion;
+23 -16
View File
@@ -19,28 +19,32 @@ import {
} from './bump-versions.mjs';
describe('generateExperimentalVersion', () => {
it('creates -exp.0 from a stable version', () => {
assert.equal(generateExperimentalVersion('1.2.3'), '1.2.3-exp.0');
it('appends -exp.g<sha> to a stable version', () => {
assert.equal(generateExperimentalVersion('1.2.3', 'abcd1234'), '1.2.3-exp.gabcd1234');
});
it('increments exp minor when already at exp.0', () => {
assert.equal(generateExperimentalVersion('1.2.3-exp.0'), '1.2.3-exp.1');
it('replaces an existing exp.g<sha> with the new sha', () => {
assert.equal(
generateExperimentalVersion('1.2.3-exp.deadbeef', 'abcd1234'),
'1.2.3-exp.gabcd1234',
);
});
it('increments exp minor when already at exp.5', () => {
assert.equal(generateExperimentalVersion('1.2.3-exp.5'), '1.2.3-exp.6');
});
it('creates -exp.0 from a version with a different pre-release tag', () => {
assert.equal(generateExperimentalVersion('1.2.3-beta.1'), '1.2.3-exp.0');
it('replaces a different pre-release tag with -exp.g<sha>', () => {
assert.equal(generateExperimentalVersion('1.2.3-beta.1', 'abcd1234'), '1.2.3-exp.gabcd1234');
});
it('handles multi-digit version numbers', () => {
assert.equal(generateExperimentalVersion('10.20.30'), '10.20.30-exp.0');
assert.equal(generateExperimentalVersion('10.20.30', 'abcd1234'), '10.20.30-exp.gabcd1234');
});
it('throws on an invalid version string', () => {
assert.throws(() => generateExperimentalVersion('not-a-version'), /Invalid version/);
assert.throws(() => generateExperimentalVersion('not-a-version', 'abcd1234'), /Invalid version/);
});
it('throws when sha is missing', () => {
// @ts-expect-error - intentionally calling without sha to verify the guard
assert.throws(() => generateExperimentalVersion('1.2.3'), /sha is required/);
});
});
@@ -358,12 +362,15 @@ describe('computeNewVersion', () => {
assert.equal(computeNewVersion('1.2.3', 'major'), '2.0.0');
});
it('creates -exp.0 from a stable version for experimental', () => {
assert.equal(computeNewVersion('1.2.3', 'experimental'), '1.2.3-exp.0');
it('appends -exp.g<sha> to a stable version for experimental', () => {
assert.equal(computeNewVersion('1.2.3', 'experimental', 'abcd1234'), '1.2.3-exp.gabcd1234');
});
it('increments exp minor for experimental when already an exp version', () => {
assert.equal(computeNewVersion('1.2.3-exp.0', 'experimental'), '1.2.3-exp.1');
it('replaces an existing exp.g<sha> with the new sha for experimental', () => {
assert.equal(
computeNewVersion('1.2.3-exp.deadbeef', 'experimental', 'abcd1234'),
'1.2.3-exp.gabcd1234',
);
});
it('creates a premajor rc version from a stable version', () => {
+1 -1
View File
@@ -9,7 +9,7 @@ import packageJson from '../../package.json' with { type: 'json' };
const baseDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const fullChangelogFile = resolve(baseDir, 'CHANGELOG.md');
// Version includes experimental versions (e.g., 1.2.3-exp.0)
// Version includes experimental versions (e.g., 1.2.3-exp.g123abcgg)
const versionChangelogFile = resolve(baseDir, `CHANGELOG-${packageJson.version}.md`);
const changelogStream = new ConventionalChangelog()
@@ -0,0 +1,82 @@
name: 'Release: Create Experiment PR'
run-name: 'Release: Create Experiment Release PR'
on:
workflow_dispatch:
inputs:
commits:
description: 'Space-separated list of commit SHAs to cherry-pick onto the new experimental branch.'
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
prepare-experiment-branch:
name: Prepare experiment branch
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
experiment-branch: ${{ steps.cherry-pick.outputs.experiment-branch }}
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ steps.generate_token.outputs.token }}
- name: Branch off stable, cherry-pick, and push
id: cherry-pick
env:
COMMITS: ${{ inputs.commits }}
run: |
set -euo pipefail
# The `stable` ref is a moving git tag pointing at the latest stable release commit.
STABLE_COMMIT="$(git rev-parse 'stable^{}')"
STABLE_TAG="$(git tag --points-at "$STABLE_COMMIT" | grep '^n8n@' | sort -V | tail -n1)"
if [ -z "$STABLE_TAG" ]; then
echo "Could not resolve stable n8n@... tag at commit $STABLE_COMMIT" >&2
exit 1
fi
STABLE_VERSION="${STABLE_TAG#n8n@}"
EXP_BRANCH="exp/${STABLE_VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "Stable tag: $STABLE_TAG"
echo "Experiment branch: $EXP_BRANCH"
git config user.email "n8n-assistant-bot@users.noreply.github.com"
git config user.name "n8n-assistant"
git switch --detach "$STABLE_TAG"
git switch --create "$EXP_BRANCH"
for sha in $COMMITS; do
echo "::group::Cherry-picking $sha"
git cherry-pick -X theirs "$sha"
echo "::endgroup::"
done
git push origin "$EXP_BRANCH"
echo "experiment-branch=$EXP_BRANCH" >> "$GITHUB_OUTPUT"
create-release-pr:
name: Create release PR
needs: [prepare-experiment-branch]
uses: ./.github/workflows/release-create-pr.yml
secrets: inherit
with:
base-branch: ${{ needs.prepare-experiment-branch.outputs.experiment-branch }}
release-type: experimental