diff --git a/.github/scripts/create-release-candidate-branch.mjs b/.github/scripts/create-release-candidate-branch.mjs deleted file mode 100644 index 3d053a2404d..00000000000 --- a/.github/scripts/create-release-candidate-branch.mjs +++ /dev/null @@ -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 ^{}` 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); -} diff --git a/.github/scripts/determine-version-info.test.mjs b/.github/scripts/determine-version-info.test.mjs index ce19bef7d2c..937ee94c411 100644 --- a/.github/scripts/determine-version-info.test.mjs +++ b/.github/scripts/determine-version-info.test.mjs @@ -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 }, diff --git a/.github/scripts/ensure-release-candidate-branches.mjs b/.github/scripts/ensure-release-candidate-branches.mjs new file mode 100644 index 00000000000..0d3bd67a467 --- /dev/null +++ b/.github/scripts/ensure-release-candidate-branches.mjs @@ -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(); +} diff --git a/.github/scripts/ensure-release-candidate-branches.test.mjs b/.github/scripts/ensure-release-candidate-branches.test.mjs new file mode 100644 index 00000000000..061da9be1ad --- /dev/null +++ b/.github/scripts/ensure-release-candidate-branches.test.mjs @@ -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', + ); + }); +}); diff --git a/.github/scripts/github-helpers.mjs b/.github/scripts/github-helpers.mjs index 78c989a043a..b16f02ab75f 100644 --- a/.github/scripts/github-helpers.mjs +++ b/.github/scripts/github-helpers.mjs @@ -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; +} diff --git a/.github/workflows/ensure-release-candidate-branch.yml b/.github/workflows/ensure-release-candidate-branch.yml deleted file mode 100644 index fea44e00732..00000000000 --- a/.github/workflows/ensure-release-candidate-branch.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Ensure release-candidate branch - -on: - workflow_dispatch: - inputs: - version: - description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)' - required: true - type: string - workflow_call: - inputs: - version: - description: 'Latest release version (e.g. 2.8.1 or n8n@2.8.1)' - required: true - type: string - outputs: - branch: - description: 'release-candidate branch name (e.g. release-candidate/2.8.x)' - value: ${{ jobs.ensure.outputs.branch }} - -jobs: - ensure: - runs-on: ubuntu-slim - - permissions: - contents: write - - outputs: - branch: ${{ steps.rc.outputs.branch }} - - steps: - - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Setup NodeJS - uses: ./.github/actions/setup-nodejs - with: - install-command: npm install --prefix=.github/scripts --no-package-lock - build-command: '' - - - name: Ensure release-candidate branch exists - id: rc - env: - VERSION: ${{ inputs.version }} - run: node ./.github/scripts/create-release-candidate-branch.mjs - - - name: Print branch - run: | - echo "Release-candidate branch: ${{ steps.rc.outputs.branch }}" diff --git a/.github/workflows/release-publish-post-release.yml b/.github/workflows/release-publish-post-release.yml index e780ff5d4fb..eac8198cde8 100644 --- a/.github/workflows/release-publish-post-release.yml +++ b/.github/workflows/release-publish-post-release.yml @@ -42,3 +42,25 @@ jobs: with: version: ${{ inputs.new_stable_version }} release-channel: stable + + ensure-release-candidate-branches: + name: Ensure release-candidate branches + if: inputs.release_type != 'rc' + runs-on: ubuntu-slim + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup NodeJS + uses: ./.github/actions/setup-nodejs + with: + install-command: npm install --prefix=.github/scripts --no-package-lock + build-command: '' + + - name: Ensure release-candidate branches + run: node ./.github/scripts/ensure-release-candidate-branches.mjs