diff --git a/.github/scripts/determine-version-info.mjs b/.github/scripts/determine-version-info.mjs new file mode 100644 index 00000000000..62226d6f376 --- /dev/null +++ b/.github/scripts/determine-version-info.mjs @@ -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> } */ + 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); +} diff --git a/.github/scripts/determine-version-info.test.mjs b/.github/scripts/determine-version-info.test.mjs new file mode 100644 index 00000000000..84015f014e6 --- /dev/null +++ b/.github/scripts/determine-version-info.test.mjs @@ -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'); + }); +}); diff --git a/.github/scripts/github-helpers.mjs b/.github/scripts/github-helpers.mjs index 89420db66e4..64e7c493055 100644 --- a/.github/scripts/github-helpers.mjs +++ b/.github/scripts/github-helpers.mjs @@ -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/..x branch, based on the n8n@ 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 + ); } /** diff --git a/.github/scripts/jsconfig.json b/.github/scripts/jsconfig.json new file mode 100644 index 00000000000..8af00d2f5cd --- /dev/null +++ b/.github/scripts/jsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "checkJs": true, + "moduleResolution": "node" + }, + "exclude": ["node_modules"] +} + diff --git a/.github/workflows/release-create-minor-pr.yml b/.github/workflows/release-create-minor-pr.yml new file mode 100644 index 00000000000..6b3a049ce72 --- /dev/null +++ b/.github/workflows/release-create-minor-pr.yml @@ -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 diff --git a/.github/workflows/release-create-pr.yml b/.github/workflows/release-create-pr.yml index 5af07452ada..82d56eaee9f 100644 --- a/.github/workflows/release-create-pr.yml +++ b/.github/workflows/release-create-pr.yml @@ -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 diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index dbe3ed51ed5..5b5b1b589b1 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -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 }} > diff --git a/.github/workflows/util-determine-current-version.yml b/.github/workflows/util-determine-current-version.yml new file mode 100644 index 00000000000..e5a5494f09e --- /dev/null +++ b/.github/workflows/util-determine-current-version.yml @@ -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 }}"