ci: Clarify and automate release process (#26181)

This commit is contained in:
Matsu 2026-02-26 09:45:31 +02:00 committed by GitHub
parent 6ec3255397
commit 96e446fc61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 415 additions and 41 deletions

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);
}

View File

@ -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');
});
});

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
.github/scripts/jsconfig.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"checkJs": true,
"moduleResolution": "node"
},
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,14 @@
name: 'Release: Create Minor Release PR'
on:
workflow_dispatch:
# schedule:
# - cron: 0 13 * * 1
jobs:
create-release-pr:
name: Create release PR
uses: ./.github/workflows/release-create-pr.yml
with:
base-branch: master
release-type: minor

View File

@ -1,6 +1,18 @@
name: 'Release: Create Pull Request'
on:
workflow_call:
inputs:
base-branch:
description: 'The branch, tag, or commit to create this release PR from.'
required: true
type: string
release-type:
description: 'A SemVer release type.'
required: true
type: string
workflow_dispatch:
inputs:
base-branch:
@ -42,9 +54,15 @@ jobs:
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
fetch-depth: 0
ref: ${{ github.event.inputs.base-branch }}
token: ${{ steps.generate_token.outputs.token }}
# Checkout base branch via separate step to prevent unsafe actions/checkout ref usage.
# poutine: untrusted_checkout_exec
- name: Switch to base branch
env:
BASE_BRANCH: ${{ github.event.inputs.base-branch }}
run: git checkout "$BASE_BRANCH"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24.13.1

View File

@ -23,8 +23,35 @@ jobs:
env:
N8N_FAIL_ON_POPULARITY_FETCH_ERROR: true
determine-version-info:
name: Determine publishing track
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
outputs:
track: ${{ steps.determine-info.outputs.track }}
version: ${{ steps.determine-info.outputs.version }}
bump: ${{ steps.determine-info.outputs.bump }}
new_stable_version: ${{ steps.determine-info.outputs.new_stable_version }}
release_type: ${{ steps.determine-info.outputs.release_type }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Determine track from package version number
id: determine-info
run: node .github/scripts/determine-version-info.mjs
publish-to-npm:
name: Publish to NPM
needs: [determine-version-info]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 20
@ -33,26 +60,10 @@ jobs:
id-token: write
env:
NPM_CONFIG_PROVENANCE: true
outputs:
release: ${{ steps.set-release.outputs.release }}
release_type: ${{ steps.set-release.outputs.release_type }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set release version in env
run: echo "RELEASE=$(node -e 'console.log(require("./package.json").version)')" >> "$GITHUB_ENV"
- name: Determine release type
id: release-type
run: |
VERSION="${{ env.RELEASE }}"
if [[ "$VERSION" == *"-rc."* ]]; then
echo "type=rc" >> "$GITHUB_OUTPUT"
else
echo "type=stable" >> "$GITHUB_OUTPUT"
fi
- name: Setup and Build
uses: ./.github/actions/setup-nodejs
env:
@ -72,7 +83,7 @@ jobs:
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
cp README.md packages/cli/README.md
sed -i "s/default: 'dev'/default: '${{ steps.release-type.outputs.type }}'/g" packages/cli/dist/config/schema.js
sed -i "s/default: 'dev'/default: '${{ needs.determine-version-info.outputs.release_type }}'/g" packages/cli/dist/config/schema.js
- name: Publish n8n to NPM with rc tag
env:
@ -88,23 +99,18 @@ jobs:
run: npm dist-tag rm n8n rc
continue-on-error: true
- id: set-release
run: |
echo "release=${{ env.RELEASE }}" >> "$GITHUB_OUTPUT"
echo "release_type=${{ steps.release-type.outputs.type }}" >> "$GITHUB_OUTPUT"
publish-to-docker-hub:
name: Publish to DockerHub
needs: [publish-to-npm, build-arm64]
needs: [determine-version-info, publish-to-npm, build-arm64]
uses: ./.github/workflows/docker-build-push.yml
with:
n8n_version: ${{ needs.publish-to-npm.outputs.release }}
release_type: ${{ needs.publish-to-npm.outputs.release_type }}
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_type: ${{ needs.determine-version-info.outputs.release_type }}
secrets: inherit
create-github-release:
name: Create a GitHub Release
needs: [publish-to-npm, publish-to-docker-hub]
needs: [determine-version-info, publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 5
@ -118,31 +124,53 @@ jobs:
uses: ncipollo/release-action@1c89adf39833729d8f85a31ccbc451b078733c80 # v1
with:
commit: ${{github.event.pull_request.base.ref}}
tag: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
prerelease: ${{ startsWith(needs.publish-to-npm.outputs.release, '2.') }}
tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
prerelease: ${{ startsWith(needs.determine-version-info.outputs.version, '2.') }}
makeLatest: false
body: ${{github.event.pull_request.body}}
move-track-tag:
name: Move track tag
needs: [determine-version-info, create-github-release]
if: github.event.pull_request.merged == true
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: ${{ needs.determine-version-info.outputs.track }}
version-tag: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
promote-stable-tag:
name: Promote stable tag (minor bump)
needs: [determine-version-info, create-github-release]
if: |
github.event.pull_request.merged == true &&
needs.determine-version-info.outputs.new_stable_version != ''
uses: ./.github/workflows/release-update-pointer-tag.yml
with:
track: stable
version-tag: 'n8n@${{ needs.determine-version-info.outputs.new_stable_version }}'
secrets: inherit
generate-and-attach-sbom:
name: Generate and Attach SBOM to Release
needs: [publish-to-npm, create-github-release]
needs: [determine-version-info, create-github-release]
uses: ./.github/workflows/sbom-generation-callable.yml
with:
n8n_version: ${{ needs.publish-to-npm.outputs.release }}
release_tag_ref: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
n8n_version: ${{ needs.determine-version-info.outputs.version }}
release_tag_ref: 'n8n@${{ needs.determine-version-info.outputs.version }}'
secrets: inherit
merge-release-tag-to-master:
name: Merge release tag to master
needs: [publish-to-npm, create-github-release]
needs: [determine-version-info, publish-to-npm, create-github-release]
if: |
github.event.pull_request.merged == true &&
contains(github.event.pull_request.labels.*.name, 'release:minor') &&
!contains(needs.publish-to-npm.outputs.release, '-rc.')
needs.determine-version-info.outputs.bump == 'minor' &&
needs.determine-version-info.outputs.release_type != 'rc'
runs-on: ubuntu-latest
environment: minor-release-tag-merge
env:
VERSION: ${{ needs.publish-to-npm.outputs.release }}
VERSION: ${{ needs.determine-version-info.outputs.version }}
steps:
- name: Generate GitHub App Token
id: generate_token
@ -185,4 +213,4 @@ jobs:
channel: '#updates-and-product-releases'
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
message: |
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| Release tag merge to master failed for n8n@${{ needs.publish-to-npm.outputs.release }} >
<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}| Release tag merge to master failed for n8n@${{ needs.determine-version-info.outputs.version }} >

View File

@ -0,0 +1,45 @@
name: 'Util: Determine current versions'
on:
workflow_dispatch:
workflow_call:
outputs:
stable:
description: 'Stable release version'
value: ${{ jobs.get-versions.outputs.stable }}
beta:
description: 'Beta release version'
value: ${{ jobs.get-versions.outputs.beta }}
v1:
description: 'v1 release version'
value: ${{ jobs.get-versions.outputs.v1 }}
jobs:
get-versions:
runs-on: ubuntu-latest
outputs:
stable: ${{ steps.get-tags.outputs.stable }}
beta: ${{ steps.get-tags.outputs.beta }}
v1: ${{ steps.get-tags.outputs.v1 }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: ''
install-command: npm install --prefix=.github/scripts --no-package-lock
- name: Extract release versions
id: get-tags
run: node ./.github/scripts/get-release-versions.mjs
- name: Print detected versions
run: |
echo "Stable: ${{ steps.get-tags.outputs.stable }}"
echo "Beta: ${{ steps.get-tags.outputs.beta }}"
echo "v1: ${{ steps.get-tags.outputs.v1 }}"