diff --git a/components/git/release.js b/components/git/release.js index c5b98c0e..e1987f5c 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -85,6 +85,8 @@ export function builder(yargs) { 'Prepare a new security release of Node.js with auto-determined version') .example('git node release --prepare --newVersion=1.2.3', 'Prepare a new release of Node.js tagged v1.2.3') + .example('git node release --prepare --newVersion=27.0.0-alpha.1.0.0', + 'Prepare a new Alpha release of Node.js tagged v27.0.0-alpha.1.0.0') .example('git node release --promote https://github.com/nodejs/node/pull/12345 https://github.com/nodejs/node/pull/54321', 'Promote two prepared releases of Node.js with PR #12345 and #54321') .example('git node --prepare --startLTS', diff --git a/docs/git-node.md b/docs/git-node.md index ed8e1460..aec97a7c 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -243,6 +243,21 @@ git node release --prepare 1.2.3 git node release --prepare ``` +```sh +# Prepare an Alpha release of Node.js. Alpha release lines are detected from +# the NODE_ALPHA_*_VERSION macros in src/node_version.h, and the alpha +# version is automatically determined when no version is given. When the +# release line has no CHANGELOG_VN.md yet, the first release +# (N.0.0-alpha.0.0.0) creates it from the commits that are not on vN-1.x. +git node release --prepare 27.0.0-alpha.1.0.0 +``` + +```sh +# Prepare the semver-major release of an Alpha release line, which marks it +# as out of Alpha. +git node release --prepare 27.0.0 +``` + ```sh # Prepare the first LTS release for a given release line git node release --prepare --startLTS diff --git a/lib/prepare_release.js b/lib/prepare_release.js index 2dff11d0..27a14a02 100644 --- a/lib/prepare_release.js +++ b/lib/prepare_release.js @@ -6,8 +6,11 @@ import semver from 'semver'; import { forceRunAsync, runAsync, runSync } from './run.js'; import { writeJson, readJson } from './file.js'; import { + formatVersion, + getAlphaVersionComponents, getEOLDate, getStartLTSBlurb, + isAlphaReleaseLine, updateTestProcessRelease } from './release/utils.js'; import CherryPick from './cherry_pick.js'; @@ -79,6 +82,61 @@ export function formatSecurityNotableChanges(commits, severityByCVE) { return lines.length ? `${lines.join('\n')}\n` : ''; } +export function filterAlphaReleaseCommits(commits) { + const alphaVersion = 'v?\\d+\\.\\d+\\.\\d+-alpha\\.\\d+\\.\\d+\\.\\d+'; + const releaseCommit = new RegExp(`\\] - (?:Working on ${alphaVersion}|` + + `\\d{4}-\\d{2}-\\d{2}, Version ${alphaVersion} \\(Alpha\\)) \\(`); + return commits.split('\n').filter(line => !releaseCommit.test(line)).join('\n'); +} + +export function parseReverts(log) { + const reverts = []; + for (const record of log.split('\x1e')) { + const [sha, body = ''] = record.trim().split('\x1f'); + for (const [, reverted] of body.matchAll(/This reverts commit ([0-9a-f]{7,40})/g)) { + reverts.push([sha, reverted]); + } + } + return reverts; +} + +export function formatMajorReleaseCommits(commits, reverts) { + const getSha = line => /^\* \\\[\[`([0-9a-f]+)`\]/.exec(line)?.[1]; + const isSameCommit = (sha, otherSha) => sha.slice(0, 10) === otherSha.slice(0, 10); + let lines = commits.split('\n').filter(line => getSha(line)); + const shas = lines.map(getSha); + const dropped = []; + for (const [revert, reverted] of reverts) { + const isListed = sha => shas.some(listed => isSameCommit(listed, sha)); + const isDropped = sha => dropped.some(d => isSameCommit(d, sha)); + if (isListed(revert) && isListed(reverted) && !isDropped(revert) && !isDropped(reverted)) { + dropped.push(revert, reverted); + } + } + lines = lines.filter(line => !dropped.some(sha => isSameCommit(sha, getSha(line)))); + + const sections = { 'Semver-Major': [], 'Semver-Minor': [], 'Semver-Patch': [] }; + for (const line of lines) { + const [, labels = ''] = /^\* \\\[\[`\w+`\]\([^)]*\)\] - \*\*\((SEMVER-[^)]+)\)\*\*/.exec(line) ?? []; + if (labels.includes('SEMVER-MAJOR')) { + sections['Semver-Major'].push(line); + } else if (labels.includes('SEMVER-MINOR')) { + sections['Semver-Minor'].push(line); + } else { + sections['Semver-Patch'].push(line); + } + } + return Object.entries(sections) + .filter(([, list]) => list.length) + .map(([title, list]) => `### ${title} Commits\n\n${list.join('\n')}\n`) + .join('\n'); +} + +export function formatReleaseInfo({ isAlpha, isLTS, ltsCodename }) { + if (isAlpha) return '(Alpha)'; + return isLTS ? `'${ltsCodename}' (LTS)` : '(Current)'; +} + export default class ReleasePreparation extends Session { constructor(argv, cli, dir) { super(cli, dir); @@ -87,6 +145,10 @@ export default class ReleasePreparation extends Session { // - string if --security=path was used this.isSecurityRelease = !!argv.security; this.securityReleaseRepo = typeof argv.security === 'string' ? argv.security : null; + this.isAlpha = false; + this.isOutOfAlpha = false; + this.isFirstRelease = false; + this.alphaVersionComponents = null; this.isLTS = false; this.isLTSTransition = argv.startLTS; this.runBranchDiff = !argv.skipBranchDiff; @@ -329,7 +391,8 @@ export default class ReleasePreparation extends Session { cli.stopSpinner(`Updated 'src/node_version.h' for ${newVersion}`); // Check whether to update NODE_MODULE_VERSION. - const isSemverMajor = versionComponents.minor === 0; + const isSemverMajor = + (this.isAlpha ? this.alphaVersionComponents : versionComponents).minor === 0; if (isSemverMajor) { const shouldUpdateNodeModuleVersion = await cli.prompt( 'Update NODE_MODULE_VERSION?', { defaultAnswer: false }); @@ -496,13 +559,13 @@ export default class ReleasePreparation extends Session { import.meta.url ); - return runSync(changelogMaker, [ + return filterAlphaReleaseCommits(runSync(changelogMaker, [ '--group', '--markdown', '--filter-release', '--start-ref', this.getLastRef(tagName) - ]).trim(); + ])).trim(); } async getLTSCodename(version) { @@ -538,10 +601,15 @@ export default class ReleasePreparation extends Session { } async updateMainChangelog() { - const { date, isLTSTransition, versionComponents, newVersion } = this; - - // Remove the leading 'v'. - const lastRef = this.getLastRef().substring(1); + const { + date, + isAlpha, + isFirstRelease, + isLTSTransition, + isOutOfAlpha, + versionComponents, + newVersion + } = this; const mainChangelogPath = path.resolve('CHANGELOG.md'); const data = await fs.readFile(mainChangelogPath, 'utf8'); @@ -550,9 +618,35 @@ export default class ReleasePreparation extends Session { const major = versionComponents.major; const hrefLink = `doc/changelogs/CHANGELOG_V${major}.md`; const newRefLink = `${newVersion}`; + + if (isFirstRelease) { + const releaseType = isAlpha ? 'Alpha' : 'Current'; + const insertBefore = (prefix, ...lines) => { + const idx = arr.findIndex(line => line.startsWith(prefix)); + if (idx === -1) { + throw new Error(`Cannot find "${prefix}" in CHANGELOG.md`); + } + arr.splice(idx, 0, ...lines); + }; + insertBefore('* [Node.js ', `* [Node.js ${major}](${hrefLink}) **${releaseType}**`); + insertBefore(' ', + ' ', `${newRefLink}
`, ' '); + await fs.writeFile(mainChangelogPath, arr.join('\n')); + return; + } + + // Remove the leading 'v'. + const lastRef = this.getLastRef().substring(1); const lastRefLink = `${lastRef}`; for (let idx = 0; idx < arr.length; idx++) { + if (isOutOfAlpha && arr[idx].includes(hrefLink)) { + arr[idx] = arr[idx].replace('**Alpha**', '**Current**'); + arr[idx] = arr[idx].replace('"Alpha"', '"Current"'); + arr[idx] = arr[idx].replace('(Alpha)', '(Current)'); + } if (isLTSTransition) { if (arr[idx].includes(hrefLink)) { const eolDate = getEOLDate(date); @@ -581,14 +675,21 @@ export default class ReleasePreparation extends Session { versionComponents, newVersion, date, + isAlpha, + isFirstRelease, isLTS, isLTSTransition, + isOutOfAlpha, isSecurityRelease, ltsCodename, username } = this; - const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)'; + if (isFirstRelease) { + return this.createMajorChangelog(); + } + + const releaseInfo = formatReleaseInfo({ isAlpha, isLTS, ltsCodename }); const lastRef = this.getLastRef(); const majorChangelogPath = path.resolve( 'doc', @@ -616,7 +717,9 @@ export default class ReleasePreparation extends Session { const newHeader = `${newVersion}
`; for (let idx = 0; idx < arr.length; idx++) { - if (isLTSTransition && arr[idx].includes('Current')) { + if (isOutOfAlpha && arr[idx] === 'Alpha') { + arr[idx] = 'Current'; + } else if (isLTSTransition && arr[idx].includes('Current')) { // Create a new column for LTS. arr.splice(idx, 0, `LTS '${ltsCodename}'`); idx++; @@ -657,6 +760,98 @@ export default class ReleasePreparation extends Session { await fs.writeFile(majorChangelogPath, arr.join('\n')); } + async createMajorChangelog() { + const { + versionComponents: { major }, + newVersion, + date, + isAlpha, + username + } = this; + + const changelogsDir = path.resolve('doc', 'changelogs'); + const previous = await fs.readFile( + path.join(changelogsDir, `CHANGELOG_V${major - 1}.md`), 'utf8'); + const [, otherVersions] = /\n\* Other Versions\n((?: {2}\* .+\n)+)/.exec(previous); + const notableChanges = await this.getBranchDiff({ + onlyNotableChanges: true, + format: 'markdown', + }); + const commits = await this.getMajorReleaseCommits(); + + const releaseType = isAlpha ? 'Alpha' : 'Current'; + const changelog = [ + `# Node.js ${major} ChangeLog`, + '', + '', + '', + '', + '', + ``, + '', + '', + '', + '', + '
${releaseType}
', + `${newVersion}
`, + '
', + '', + '* Other Versions', + ` * [${major - 1}.x](CHANGELOG_V${major - 1}.md)`, + `${otherVersions}`, + ``, + '', + `## ${date}, Version ${newVersion} ${formatReleaseInfo({ isAlpha })}, @${username}`, + '', + '### Notable Changes', + '', + ...(notableChanges.trim() ? [notableChanges] : []), + commits + ].join('\n'); + await fs.writeFile(path.join(changelogsDir, `CHANGELOG_V${major}.md`), changelog); + + const link = ` * [${major}.x](CHANGELOG_V${major}.md)`; + for (const file of await fs.readdir(changelogsDir)) { + if (!/^CHANGELOG_.+\.md$/.test(file) || file === `CHANGELOG_V${major}.md`) continue; + const filePath = path.join(changelogsDir, file); + const data = await fs.readFile(filePath, 'utf8'); + if (data.includes(link)) continue; + await fs.writeFile(filePath, + data.replace('\n* Other Versions\n', `\n* Other Versions\n${link}\n`)); + } + } + + async getMajorReleaseCommits() { + const { upstream, versionComponents, newVersion } = this; + const previousReleaseBranch = `v${versionComponents.major - 1}.x`; + const proposalBranch = `v${newVersion}-proposal`; + + await forceRunAsync('git', + ['remote', 'set-branches', '--add', upstream, previousReleaseBranch], + { ignoreFailures: false }); + await forceRunAsync('git', ['fetch', upstream, previousReleaseBranch], + { ignoreFailures: false }); + + const branchDiff = new URL( + '../node_modules/.bin/branch-diff' + (isWindows ? '.cmd' : ''), + import.meta.url + ); + const commits = runSync(branchDiff, [ + `${upstream}/${previousReleaseBranch}`, + proposalBranch, + '--format=markdown', + '--group', + '--filter-release' + ]); + const reverts = runSync('git', [ + 'log', + '--format=%H%x1f%b%x1e', + '--grep=^This reverts commit', + `${upstream}/${previousReleaseBranch}..${proposalBranch}` + ]); + return formatMajorReleaseCommits(filterAlphaReleaseCommits(commits), parseReverts(reverts)); + } + async createProposalBranch(base = this.branch) { const { newVersion } = this; const proposalBranch = `v${newVersion}-proposal`; @@ -671,7 +866,7 @@ export default class ReleasePreparation extends Session { } async updateNodeVersion() { - const { ltsCodename, versionComponents } = this; + const { alphaVersionComponents, isAlpha, ltsCodename, versionComponents } = this; const filePath = path.resolve('src', 'node_version.h'); const data = await fs.readFile(filePath, 'utf8'); @@ -686,6 +881,12 @@ export default class ReleasePreparation extends Session { arr[idx] = `#define NODE_PATCH_VERSION ${versionComponents.patch}`; } else if (line.includes('#define NODE_VERSION_IS_RELEASE')) { arr[idx] = '#define NODE_VERSION_IS_RELEASE 1'; + } else if (isAlpha && line.includes('#define NODE_ALPHA_MAJOR_VERSION')) { + arr[idx] = `#define NODE_ALPHA_MAJOR_VERSION ${alphaVersionComponents.major}`; + } else if (isAlpha && line.includes('#define NODE_ALPHA_MINOR_VERSION')) { + arr[idx] = `#define NODE_ALPHA_MINOR_VERSION ${alphaVersionComponents.minor}`; + } else if (isAlpha && line.includes('#define NODE_ALPHA_PATCH_VERSION')) { + arr[idx] = `#define NODE_ALPHA_PATCH_VERSION ${alphaVersionComponents.patch}`; } else if (line.includes('#define NODE_VERSION_IS_LTS')) { this.isLTS = arr[idx].split(' ')[2] === '1'; if (this.isLTSTransition) { @@ -701,7 +902,30 @@ export default class ReleasePreparation extends Session { } }); - await fs.writeFile(filePath, arr.join('\n')); + let updated = arr.join('\n'); + if (!isAlpha) { + updated = updated.replace(/\n\n(?:#define NODE_ALPHA_\w+ \d+\n)+/, '\n'); + } + + await fs.writeFile(filePath, updated); + } + + readIsAlphaReleaseLine() { + const filePath = path.resolve('src', 'node_version.h'); + return isAlphaReleaseLine(readFileSync(filePath, 'utf8')); + } + + readIsFirstRelease(major) { + return !existsSync(path.resolve('doc', 'changelogs', `CHANGELOG_V${major}.md`)); + } + + checkAlphaReleaseLine(isAlphaLine) { + if (isAlphaLine && this.isLTSTransition) { + throw new Error('Cannot use --startLTS on an Alpha release line. Release ' + + 'the line out of Alpha (e.g. --newVersion=N.0.0) before it can move to LTS.'); + } + // A non-Alpha release on an Alpha release line marks it as out of Alpha. + this.isOutOfAlpha = isAlphaLine && !this.isAlpha; } updateNodeModuleVersion(runtime, variant, versions) { @@ -724,6 +948,7 @@ export default class ReleasePreparation extends Session { async createReleaseCommit() { const { cli, + isAlpha, isLTS, isLTSTransition, ltsCodename, @@ -732,7 +957,7 @@ export default class ReleasePreparation extends Session { date } = this; - const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)'; + const releaseInfo = formatReleaseInfo({ isAlpha, isLTS, ltsCodename }); const messageTitle = `${date}, Version ${newVersion} ${releaseInfo}`; const messageBody = []; @@ -833,12 +1058,15 @@ export default class ReleasePreparation extends Session { let branchDiffOptions; if (opts.onlyNotableChanges) { const proposalBranch = `v${newVersion}-proposal`; - const releaseBranch = `v${majorVersion}.x`; + const releaseBranch = `v${this.isFirstRelease ? majorVersion - 1 : majorVersion}.x`; const notableLabels = [ 'notable-change', - 'semver-minor' + this.isFirstRelease ? 'semver-major' : 'semver-minor' ]; + if (this.isAlpha && !this.isFirstRelease) { + notableLabels.push('semver-major'); + } await forceRunAsync('git', ['remote', 'set-branches', '--add', upstream, releaseBranch], { ignoreFailures: false @@ -853,7 +1081,7 @@ export default class ReleasePreparation extends Session { ]; } else { const excludeLabels = [ - 'semver-major', + ...(this.isAlpha ? [] : ['semver-major']), `dont-land-on-v${majorVersion}.x`, `backport-requested-v${majorVersion}.x`, `backported-to-v${majorVersion}.x`, @@ -923,17 +1151,23 @@ export default class ReleasePreparation extends Session { path.resolve(`doc/changelogs/CHANGELOG_V${major}.md`), 'utf8' ); - const [,, minor, patch] = /\1\.\2\.\3<\/a>/.exec(data); + const [, version, minor, patch, alpha] = + /\1<\/a>/.exec(data); this.isLTS = data.includes('LTS '); - cli.stopSpinner(`Latest release on ${major}.x line is ${major}.${minor}.${patch}${ + cli.stopSpinner(`Latest release on ${major}.x line is ${version}${ this.isLTS ? ' (LTS)' : '' }`); - return { - tagName: await this.getLastRef(`v${major}.${minor}.${patch}`), + const lastRelease = { + tagName: await this.getLastRef(`v${version}`), major, minor, patch }; + if (alpha) { + const [alphaMajor, alphaMinor, alphaPatch] = alpha.split('.').map(Number); + lastRelease.alpha = { major: alphaMajor, minor: alphaMinor, patch: alphaPatch }; + } + return lastRelease; } async prepareLocalBranch() { @@ -945,6 +1179,12 @@ export default class ReleasePreparation extends Session { cli.warn(`${this.newVersion} is not a valid semantic version.`); return; } + this.alphaVersionComponents = getAlphaVersionComponents(newVersion); + this.isAlpha = this.alphaVersionComponents !== null; + if (!this.isAlpha && newVersion.prerelease[0] === 'alpha') { + throw new Error(`${this.newVersion} is not a valid Alpha version, ` + + 'expected N.0.0-alpha.X.Y.Z.'); + } this.newVersion = newVersion.version; this.versionComponents = { major: newVersion.major, @@ -954,7 +1194,16 @@ export default class ReleasePreparation extends Session { this.stagingBranch = `v${newVersion.major}.x-staging`; this.releaseBranch = `v${newVersion.major}.x`; await this.tryResetBranch(); - await this.getLastRelease(newVersion.major); + const isAlphaLine = this.readIsAlphaReleaseLine(); + if (this.isAlpha && !isAlphaLine) { + throw new Error(`Cannot prepare Alpha release ${this.newVersion}: ` + + 'src/node_version.h does not define the NODE_ALPHA_*_VERSION macros.'); + } + this.checkAlphaReleaseLine(isAlphaLine); + this.isFirstRelease = this.readIsFirstRelease(newVersion.major); + if (!this.isFirstRelease) { + await this.getLastRelease(newVersion.major); + } return; } @@ -982,9 +1231,34 @@ export default class ReleasePreparation extends Session { // the new version is only calculated below, and it may bump the major. this.releaseBranch = `v${match[1]}.x`; await this.tryResetBranch(); - this.versionComponents = await this.calculateNewVersion(await this.getLastRelease(match[1])); - const { major, minor, patch } = this.versionComponents; - this.newVersion = `${major}.${minor}.${patch}`; + this.isAlpha = this.readIsAlphaReleaseLine(); + this.checkAlphaReleaseLine(this.isAlpha); + this.isFirstRelease = this.readIsFirstRelease(match[1]); + if (this.isFirstRelease) { + this.versionComponents = { major: Number(match[1]), minor: 0, patch: 0 }; + if (this.isAlpha) { + this.alphaVersionComponents = { major: 0, minor: 0, patch: 0 }; + } + this.newVersion = formatVersion(this.versionComponents, this.alphaVersionComponents); + return; + } + const lastRelease = await this.getLastRelease(match[1]); + if (this.isAlpha) { + if (!lastRelease.alpha) { + throw new Error(`Cannot calculate the next Alpha version from ${lastRelease.tagName}. ` + + 'Use --newVersion=N.0.0-alpha.X.Y.Z instead.'); + } + const { major, minor, patch } = lastRelease; + this.versionComponents = { major, minor, patch }; + this.alphaVersionComponents = await this.calculateNewVersion({ + tagName: lastRelease.tagName, + ...lastRelease.alpha + }); + } else { + this.versionComponents = await this.calculateNewVersion(lastRelease); + } + const { major } = this.versionComponents; + this.newVersion = formatVersion(this.versionComponents, this.alphaVersionComponents); this.releaseBranch = `v${major}.x`; } diff --git a/lib/promote_release.js b/lib/promote_release.js index 2e3daf31..f98a8659 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -9,6 +9,13 @@ import { forceRunAsync, runSync } from './run.js'; import PRData from './pr_data.js'; import PRChecker from './pr_checker.js'; import Session from './session.js'; +import { + formatVersion, + getAlphaVersionComponents, + incrementVersion, + updateNodeVersionForNextMajor, + updateNodeVersionForNextRelease +} from './release/utils.js'; import { existsSync } from 'node:fs'; const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run ' + @@ -72,7 +79,15 @@ export default class ReleasePromotion extends Session { const releaseData = await this.parseDataFromReleaseCommit(releaseCommitSha); - const { version, isLTS, ltsCodename, date, versionComponents } = releaseData; + const { + version, + isAlpha, + isLTS, + ltsCodename, + date, + versionComponents, + alphaVersionComponents + } = releaseData; cli.startSpinner('Verifying Jenkins CI status'); if (!jenkinsReady) { cli.stopSpinner( @@ -120,13 +135,15 @@ export default class ReleasePromotion extends Session { cli.warn(`Aborting release promotion for version ${version}`); throw new Error('Aborted'); } - await this.secureTagRelease({ version, isLTS, ltsCodename, releaseCommitSha, date }); + await this.secureTagRelease({ version, isAlpha, isLTS, ltsCodename, releaseCommitSha, date }); await this.verifyTagSignature(version); // Set up for next release. cli.startSpinner('Setting up for next release'); const workingOnNewReleaseCommit = - await this.setupForNextRelease({ prid, owner, repo, versionComponents }); + await this.setupForNextRelease({ + prid, owner, repo, versionComponents, alphaVersionComponents + }); cli.stopSpinner('Successfully set up for next release'); const shouldRebaseStagingBranch = await cli.prompt( @@ -151,8 +168,8 @@ export default class ReleasePromotion extends Session { } const defaultBranch = await this.checkoutDefaultBranch(); - for (const { releaseCommitSha } of releases) { - await this.cherryPickReleaseCommit(releaseCommitSha); + for (const { releaseCommitSha, isAlpha, versionComponents } of releases) { + await this.cherryPickReleaseCommit(releaseCommitSha, { isAlpha, versionComponents }); } // Push to the remote the release tag(s), and default, release, and staging branches. @@ -179,15 +196,16 @@ export default class ReleasePromotion extends Session { cli.separator(); cli.info('Use the following command(s) to create the GitHub release(s):'); cli.separator(); - for (const { date, version, versionComponents, isLTS, releaseTitle } of releases) { + for (const { date, version, versionComponents, isAlpha, isLTS, releaseTitle } of releases) { cli.info( 'awk \'' + `/^## ${date}, Version ${version.replaceAll('.', '\\.')} /,` + - '/^<\\x2fa>$/{' + + '/^<\\x2fa>$/{' + 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' + `}' doc/changelogs/CHANGELOG_V${ versionComponents.major}.md | gh release create v${version} --verify-tag --latest${ - isLTS ? '=false' : ''} --title=${JSON.stringify(releaseTitle)} --notes-file -`); + isLTS || isAlpha ? '=false' : ''}${isAlpha ? ' --prerelease' : ''} --title=${ + JSON.stringify(releaseTitle)} --notes-file -`); } } @@ -289,10 +307,21 @@ export default class ReleasePromotion extends Session { }; // Parse out LTS status and codename. - if (!releaseCommitMessage.endsWith(' (Current)')) { + data.alphaVersionComponents = getAlphaVersionComponents(version); + if (releaseCommitMessage.endsWith(' (Alpha)')) { + if (data.alphaVersionComponents == null) { + cli.error('Invalid release commit, Alpha releases must use the N.0.0-alpha.X.Y.Z format'); + throw new Error('Aborted'); + } + data.isAlpha = true; + } else if (data.alphaVersionComponents != null) { + cli.error('Invalid release commit, Alpha versions must use the Alpha release format'); + throw new Error('Aborted'); + } else if (!releaseCommitMessage.endsWith(' (Current)')) { const match = /'([^']+)' \(LTS\)$/.exec(releaseCommitMessage); if (match == null) { - cli.error('Invalid release commit, it should match either Current or LTS release format'); + cli.error( + 'Invalid release commit, it should match either Alpha, Current or LTS release format'); throw new Error('Aborted'); } data.isLTS = true; @@ -343,8 +372,9 @@ export default class ReleasePromotion extends Session { return releaseCommitData; } - async secureTagRelease({ version, isLTS, ltsCodename, releaseCommitSha, date }) { - const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; + async secureTagRelease({ version, isAlpha, isLTS, ltsCodename, releaseCommitSha, date }) { + let releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; + if (isAlpha) releaseInfo = '(Alpha)'; try { await new Promise((resolve, reject) => { @@ -370,27 +400,17 @@ export default class ReleasePromotion extends Session { // Set up the branch so that nightly builds are produced with the next // version number and a pre-release tag. - async setupForNextRelease({ prid, owner, repo, versionComponents }) { + async setupForNextRelease({ prid, owner, repo, versionComponents, alphaVersionComponents }) { // Update node_version.h for next patch release. const filePath = path.resolve('src', 'node_version.h'); - const nodeVersionFile = await fs.open(filePath, 'r+'); - - const patchVersion = versionComponents.patch + 1; - let cursor = 0; - for await (const line of nodeVersionFile.readLines({ autoClose: false })) { - cursor += line.length + 1; - if (line === `#define NODE_PATCH_VERSION ${versionComponents.patch}`) { - await nodeVersionFile.write(`${patchVersion}`, cursor - 2, 'ascii'); - } else if (line === '#define NODE_VERSION_IS_RELEASE 1') { - await nodeVersionFile.write('0', cursor - 2, 'ascii'); - break; - } - } - - await nodeVersionFile.close(); + const data = await fs.readFile(filePath, 'utf8'); + await fs.writeFile(filePath, updateNodeVersionForNextRelease(data, { + versionComponents, + alphaVersionComponents + })); const workingOnVersion = - `v${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; + `v${incrementVersion(formatVersion(versionComponents, alphaVersionComponents), 'patch')}`; // Create 'Working On' commit. await forceRunAsync('git', ['add', filePath], { ignoreFailure: false }); @@ -500,8 +520,10 @@ export default class ReleasePromotion extends Session { } } - async cherryPickReleaseCommit(releaseCommitSha) { + async cherryPickReleaseCommit(releaseCommitSha, { isAlpha, versionComponents } = {}) { const { cli } = this; + const isMajorRelease = + !isAlpha && versionComponents?.minor === 0 && versionComponents?.patch === 0; const appliedCleanly = await this.cherryPick(releaseCommitSha); // Ensure `node_version.h`'s `NODE_VERSION_IS_RELEASE` bit is not updated @@ -511,6 +533,13 @@ export default class ReleasePromotion extends Session { : 'HEAD', // In case of conflict, HEAD is still the top of the remove branch. '--', 'src/node_version.h'], { ignoreFailure: false }); + if (isMajorRelease) { + const filePath = path.resolve('src', 'node_version.h'); + const data = await fs.readFile(filePath, 'utf8'); + await fs.writeFile(filePath, + updateNodeVersionForNextMajor(data, versionComponents.major + 1)); + await forceRunAsync('git', ['add', filePath], { ignoreFailure: false }); + } if (appliedCleanly) { // There were no conflicts, we have to amend the commit to revert the @@ -540,7 +569,8 @@ export default class ReleasePromotion extends Session { { captureStdout: true, ignoreFailure: false }); const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n'); await this.validateReleaseCommit(commitTitle); - if (!modifiedFiles.some(file => !file.endsWith('.md'))) break; + if (!modifiedFiles.some(file => !file.endsWith('.md') && + !(isMajorRelease && file === 'src/node_version.h'))) break; cli.warn( 'Some modified files are not markdown, that\'s unusual. Consider amending the commit.' diff --git a/lib/release/utils.js b/lib/release/utils.js index f4fec996..e73ae98e 100644 --- a/lib/release/utils.js +++ b/lib/release/utils.js @@ -1,3 +1,5 @@ +import semver from 'semver'; + export function getEOLDate(ltsStartDate) { // Maintenance LTS lasts for 18 months. const result = getLTSMaintenanceStartDate(ltsStartDate); @@ -49,3 +51,62 @@ export function updateTestProcessRelease(test, options) { } return outLines.join('\n'); } + +export function getAlphaVersionComponents(version) { + const [tag, ...components] = version.prerelease; + if (tag !== 'alpha' || components.length !== 3 || + !components.every(Number.isInteger) || + version.minor !== 0 || version.patch !== 0) { + return null; + } + const [major, minor, patch] = components; + return { major, minor, patch }; +} + +export function isAlphaReleaseLine(nodeVersionH) { + return /^#define NODE_ALPHA_MAJOR_VERSION \d+$/m.test(nodeVersionH); +} + +export function formatVersion({ major, minor, patch }, alphaVersionComponents) { + const version = `${major}.${minor}.${patch}`; + if (!alphaVersionComponents) return version; + return `${version}-alpha.${formatVersion(alphaVersionComponents)}`; +} + +export function updateNodeVersionForNextRelease( + nodeVersionH, { versionComponents, alphaVersionComponents }) { + const macro = alphaVersionComponents ? 'NODE_ALPHA_PATCH_VERSION' : 'NODE_PATCH_VERSION'; + const { patch } = alphaVersionComponents ?? versionComponents; + return nodeVersionH + .replace(new RegExp(`^#define ${macro} ${patch}$`, 'm'), `#define ${macro} ${patch + 1}`) + .replace(/^#define NODE_VERSION_IS_RELEASE 1$/m, '#define NODE_VERSION_IS_RELEASE 0'); +} + +export function updateNodeVersionForNextMajor(nodeVersionH, major) { + let updated = nodeVersionH + .replace(/^#define NODE_MAJOR_VERSION \d+$/m, `#define NODE_MAJOR_VERSION ${major}`) + .replace(/^#define NODE_MINOR_VERSION \d+$/m, '#define NODE_MINOR_VERSION 0') + .replace(/^#define NODE_PATCH_VERSION \d+$/m, '#define NODE_PATCH_VERSION 0') + .replace(/^#define NODE_VERSION_IS_RELEASE \d+$/m, '#define NODE_VERSION_IS_RELEASE 0') + .replace(/^#define (NODE_ALPHA_\w+_VERSION) \d+$/gm, '#define $1 0'); + if (!isAlphaReleaseLine(updated)) { + updated = updated.replace(/^#define NODE_VERSION_IS_RELEASE 0\n/m, [ + '#define NODE_VERSION_IS_RELEASE 0', + '', + '#define NODE_ALPHA_MAJOR_VERSION 0', + '#define NODE_ALPHA_MINOR_VERSION 0', + '#define NODE_ALPHA_PATCH_VERSION 0', + '' + ].join('\n')); + } + return updated; +} + +export function incrementVersion(version, release) { + const parsed = semver.parse(version); + const alpha = parsed && getAlphaVersionComponents(parsed); + if (!alpha) return semver.inc(version, release); + return formatVersion(parsed, release === 'minor' + ? { major: alpha.major, minor: alpha.minor + 1, patch: 0 } + : { ...alpha, patch: alpha.patch + 1 }); +} diff --git a/lib/update_security_release.js b/lib/update_security_release.js index 5fd6db89..984b8477 100644 --- a/lib/update_security_release.js +++ b/lib/update_security_release.js @@ -15,6 +15,7 @@ import auth from './auth.js'; import Request from './request.js'; import nv from '@pkgjs/nv'; import semver from 'semver'; +import { incrementVersion } from './release/utils.js'; export default class UpdateSecurityRelease extends SecurityRelease { async sync() { @@ -518,13 +519,14 @@ Summary: ${summary}\n`, for (const affectedVersion of affectedVersions) { if (!versionCache.has(affectedVersion)) { const affectedMajor = affectedVersion.split('.')[0]; - const latest = supportedVersions.find((v) => v.major === Number(affectedMajor)).version; + const latest = + supportedVersions.find((v) => v.major === Number(affectedMajor))?.version ?? ''; const version = await this.cli.prompt( `What is the affected version (<=) for release line ${affectedVersion}?`, { questionType: 'input', defaultAnswer: latest }); - const nextPatchVersion = semver.inc(version, 'patch'); - const nextMinorVersion = semver.inc(version, 'minor'); + const nextPatchVersion = incrementVersion(version, 'patch'); + const nextMinorVersion = incrementVersion(version, 'minor'); const patchedVersion = await this.cli.promptRadio( `What is the patched version (>=) for release line ${affectedVersion}?`, [nextPatchVersion, nextMinorVersion], diff --git a/test/unit/prepare_release.test.js b/test/unit/prepare_release.test.js index 5cf18e5e..fe36a28a 100644 --- a/test/unit/prepare_release.test.js +++ b/test/unit/prepare_release.test.js @@ -1,14 +1,100 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { readFileSync } from 'node:fs'; +import fs, { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import semver from 'semver'; import * as utils from '../../lib/release/utils.js'; -import { +import ReleasePreparation, { parsePullRequestURL, getPullRequestURLForLine, - formatSecurityNotableChanges + filterAlphaReleaseCommits, + formatMajorReleaseCommits, + formatReleaseInfo, + formatSecurityNotableChanges, + parseReverts } from '../../lib/prepare_release.js'; +function nodeVersionH({ major, minor, patch, isRelease = 0, alpha }) { + return [ + '#ifndef SRC_NODE_VERSION_H_', + '#define SRC_NODE_VERSION_H_', + '', + `#define NODE_MAJOR_VERSION ${major}`, + `#define NODE_MINOR_VERSION ${minor}`, + `#define NODE_PATCH_VERSION ${patch}`, + '', + '#define NODE_VERSION_IS_LTS 0', + '#define NODE_VERSION_LTS_CODENAME ""', + '', + `#define NODE_VERSION_IS_RELEASE ${isRelease}`, + '', + ...(alpha + ? [ + `#define NODE_ALPHA_MAJOR_VERSION ${alpha.major}`, + `#define NODE_ALPHA_MINOR_VERSION ${alpha.minor}`, + `#define NODE_ALPHA_PATCH_VERSION ${alpha.patch}`, + '' + ] + : []), + '#ifndef NODE_STRINGIFY', + '' + ].join('\n'); +} + +const changelogV27 = [ + '# Node.js 27 ChangeLog', + '', + '', + '', + '', + '', + '', + '', + '', + '
Current
', + '27.0.0-alpha.0.0.0
', + '
', + '', + '
', + '', + '## 2026-10-28, Version 27.0.0-alpha.0.0.0 (Alpha), @RafaelGSS', + '' +].join('\n'); + +function nodeCheckout(t, files) { + const previous = process.cwd(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ncu-prepare-release-')); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); + } + t.after(() => { + process.chdir(previous); + fs.rmSync(dir, { recursive: true, force: true }); + }); + process.chdir(dir); + return dir; +} + +function releasePreparation(props) { + const noop = () => {}; + return Object.assign(Object.create(ReleasePreparation.prototype), { + cli: { startSpinner: noop, stopSpinner: noop, warn: noop }, + config: { username: 'RafaelGSS' }, + isAlpha: false, + isOutOfAlpha: false, + isFirstRelease: false, + alphaVersionComponents: null, + isLTS: false, + ltsCodename: '', + async tryResetBranch() {}, + getLastRef(tagName) { return tagName ?? 'v27.0.0-alpha.0.0.0'; } + }, props); +} + describe('prepare_release: utils.getEOLDate', () => { it('calculates the correct EOL date', () => { const test = utils.getEOLDate('2020-10-27'); @@ -204,3 +290,592 @@ describe('prepare_release: formatSecurityNotableChanges', () => { assert.strictEqual(formatSecurityNotableChanges([], severityByCVE), ''); }); }); + +describe('prepare_release: utils.getAlphaVersionComponents', () => { + it('parses the alpha components of an Alpha version', () => { + assert.deepStrictEqual( + utils.getAlphaVersionComponents(semver.parse('27.0.0-alpha.0.0.0')), + { major: 0, minor: 0, patch: 0 } + ); + assert.deepStrictEqual( + utils.getAlphaVersionComponents(semver.parse('v27.0.0-alpha.1.12.3')), + { major: 1, minor: 12, patch: 3 } + ); + }); + + it('returns null for versions that are not N.0.0-alpha.X.Y.Z', () => { + for (const version of [ + '27.0.0', '26.1.0', '27.0.0-rc.1', '27.0.0-alpha.1', + '27.0.0-alpha.1.0', '27.0.0-alpha.1.0.0.0', '27.1.0-alpha.0.0.0', + '27.0.1-alpha.0.0.0', '27.0.0-alpha.x.0.0' + ]) { + assert.strictEqual( + utils.getAlphaVersionComponents(semver.parse(version)), null, version); + } + }); +}); + +describe('prepare_release: utils.isAlphaReleaseLine', () => { + it('detects the NODE_ALPHA_*_VERSION macros', () => { + assert.strictEqual(utils.isAlphaReleaseLine(nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 0 } + })), true); + assert.strictEqual(utils.isAlphaReleaseLine(nodeVersionH({ + major: 26, minor: 12, patch: 0 + })), false); + }); +}); + +describe('prepare_release: utils.formatVersion', () => { + it('formats Current and Alpha versions', () => { + assert.strictEqual(utils.formatVersion({ major: 26, minor: 12, patch: 1 }), '26.12.1'); + assert.strictEqual( + utils.formatVersion({ major: 27, minor: 0, patch: 0 }, { major: 1, minor: 0, patch: 2 }), + '27.0.0-alpha.1.0.2' + ); + }); +}); + +describe('prepare_release: formatReleaseInfo', () => { + it('formats the release type of Alpha, Current and LTS releases', () => { + assert.strictEqual(formatReleaseInfo({ isAlpha: true }), '(Alpha)'); + assert.strictEqual(formatReleaseInfo({ isLTS: false }), '(Current)'); + assert.strictEqual( + formatReleaseInfo({ isLTS: true, ltsCodename: 'Krypton' }), '\'Krypton\' (LTS)'); + }); +}); + +describe('prepare_release: calculateNewVersion', () => { + const cases = [ + ['* [abc] - **(SEMVER-MAJOR)** lib: change', { major: 1, minor: 0, patch: 0 }], + ['* [abc] - **(SEMVER-MINOR)** lib: add', { major: 0, minor: 2, patch: 0 }], + ['* [abc] - lib: fix', { major: 0, minor: 1, patch: 3 }] + ]; + for (const [changelog, expected] of cases) { + it(`bumps ${JSON.stringify(expected)} for ${changelog}`, async() => { + const release = releasePreparation({ getChangelog: () => changelog }); + assert.deepStrictEqual( + await release.calculateNewVersion({ + tagName: 'v27.0.0-alpha.0.1.2', major: 0, minor: 1, patch: 2 + }), + expected + ); + }); + } +}); + +describe('prepare_release: getLastRelease', () => { + it('parses the latest Alpha release from the changelog', async(t) => { + nodeCheckout(t, { 'doc/changelogs/CHANGELOG_V27.md': changelogV27 }); + const release = releasePreparation(); + assert.deepStrictEqual(await release.getLastRelease('27'), { + tagName: 'v27.0.0-alpha.0.0.0', + major: '27', + minor: '0', + patch: '0', + alpha: { major: 0, minor: 0, patch: 0 } + }); + }); + + it('parses the latest Current release from the changelog', async(t) => { + nodeCheckout(t, { + 'doc/changelogs/CHANGELOG_V26.md': + '26.12.0
\n26.11.1
\n' + }); + const release = releasePreparation(); + assert.deepStrictEqual(await release.getLastRelease('26'), { + tagName: 'v26.12.0', + major: '26', + minor: '12', + patch: '0' + }); + }); +}); + +describe('prepare_release: prepareLocalBranch', () => { + const alphaLineFiles = { + 'src/node_version.h': nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 1 } + }), + 'doc/changelogs/CHANGELOG_V27.md': changelogV27 + }; + + it('calculates the next Alpha version on an Alpha release line', async(t) => { + nodeCheckout(t, alphaLineFiles); + const release = releasePreparation({ + getCurrentBranch: () => 'v27.x-staging', + getChangelog: () => '* [abc] - **(SEMVER-MAJOR)** lib: change' + }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isAlpha, true); + assert.strictEqual(release.newVersion, '27.0.0-alpha.1.0.0'); + assert.deepStrictEqual(release.versionComponents, { major: '27', minor: '0', patch: '0' }); + assert.deepStrictEqual(release.alphaVersionComponents, { major: 1, minor: 0, patch: 0 }); + assert.strictEqual(release.releaseBranch, 'v27.x'); + }); + + it('accepts an Alpha --newVersion on an Alpha release line', async(t) => { + nodeCheckout(t, alphaLineFiles); + const release = releasePreparation({ newVersion: '27.0.0-alpha.0.1.0' }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isAlpha, true); + assert.strictEqual(release.newVersion, '27.0.0-alpha.0.1.0'); + assert.deepStrictEqual(release.versionComponents, { major: 27, minor: 0, patch: 0 }); + assert.deepStrictEqual(release.alphaVersionComponents, { major: 0, minor: 1, patch: 0 }); + assert.strictEqual(release.stagingBranch, 'v27.x-staging'); + }); + + it('treats a non-Alpha --newVersion on an Alpha line as the stable release', async(t) => { + nodeCheckout(t, alphaLineFiles); + const release = releasePreparation({ newVersion: '27.0.0' }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isAlpha, false); + assert.strictEqual(release.newVersion, '27.0.0'); + }); + + it('rejects malformed Alpha versions', async(t) => { + nodeCheckout(t, alphaLineFiles); + const release = releasePreparation({ newVersion: '27.0.0-alpha.1' }); + await assert.rejects(release.prepareLocalBranch(), /not a valid Alpha version/); + }); + + it('rejects Alpha versions on a release line without the alpha macros', async(t) => { + nodeCheckout(t, { + 'src/node_version.h': nodeVersionH({ major: 26, minor: 12, patch: 1 }) + }); + const release = releasePreparation({ newVersion: '26.0.0-alpha.0.0.0' }); + await assert.rejects(release.prepareLocalBranch(), /NODE_ALPHA_\*_VERSION/); + }); +}); + +describe('prepare_release: updateNodeVersion', () => { + it('sets the alpha components for an Alpha release', async(t) => { + nodeCheckout(t, { + 'src/node_version.h': nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 1 } + }) + }); + const release = releasePreparation({ + isAlpha: true, + versionComponents: { major: 27, minor: 0, patch: 0 }, + alphaVersionComponents: { major: 1, minor: 0, patch: 0 } + }); + await release.updateNodeVersion(); + assert.strictEqual( + fs.readFileSync('src/node_version.h', 'utf8'), + nodeVersionH({ + major: 27, minor: 0, patch: 0, isRelease: 1, alpha: { major: 1, minor: 0, patch: 0 } + }) + ); + }); + + it('removes the alpha macros when the release line goes stable', async(t) => { + nodeCheckout(t, { + 'src/node_version.h': nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 3, minor: 1, patch: 1 } + }) + }); + const release = releasePreparation({ + versionComponents: { major: 27, minor: 0, patch: 0 } + }); + await release.updateNodeVersion(); + assert.strictEqual( + fs.readFileSync('src/node_version.h', 'utf8'), + nodeVersionH({ major: 27, minor: 0, patch: 0, isRelease: 1 }) + ); + }); + + it('updates a Current release line without alpha macros', async(t) => { + nodeCheckout(t, { + 'src/node_version.h': nodeVersionH({ major: 26, minor: 12, patch: 1 }) + }); + const release = releasePreparation({ + versionComponents: { major: 26, minor: 13, patch: 0 } + }); + await release.updateNodeVersion(); + assert.strictEqual( + fs.readFileSync('src/node_version.h', 'utf8'), + nodeVersionH({ major: 26, minor: 13, patch: 0, isRelease: 1 }) + ); + }); +}); + +describe('prepare_release: changelogs', () => { + const alphaRelease = () => releasePreparation({ + isAlpha: true, + date: '2026-11-10', + newVersion: '27.0.0-alpha.1.0.0', + versionComponents: { major: 27, minor: 0, patch: 0 }, + alphaVersionComponents: { major: 1, minor: 0, patch: 0 }, + getChangelog: () => '* \\[[`abc`](url)] - **(SEMVER-MAJOR)** lib: change', + getBranchDiff: async() => '* lib: change\n' + }); + + it('adds an Alpha release to CHANGELOG_V27.md', async(t) => { + nodeCheckout(t, { 'doc/changelogs/CHANGELOG_V27.md': changelogV27 }); + await alphaRelease().updateMajorChangelog(); + const changelog = fs.readFileSync('doc/changelogs/CHANGELOG_V27.md', 'utf8'); + assert.match(changelog, new RegExp([ + '', + '27\\.0\\.0-alpha\\.1\\.0\\.0
', + '27\\.0\\.0-alpha\\.0\\.0\\.0
' + ].join('\n'))); + assert.ok(changelog.includes([ + '', + '', + '## 2026-11-10, Version 27.0.0-alpha.1.0.0 (Alpha), @RafaelGSS', + '', + '### Notable Changes', + '', + '* lib: change', + '', + '### Commits', + '', + '* \\[[`abc`](url)] - **(SEMVER-MAJOR)** lib: change', + '', + '' + ].join('\n'))); + }); + + it('adds an Alpha release to CHANGELOG.md', async(t) => { + const link = 'doc/changelogs/CHANGELOG_V27.md'; + nodeCheckout(t, { + 'CHANGELOG.md': + `27.0.0-alpha.0.0.0
\n` + }); + await alphaRelease().updateMainChangelog(); + assert.strictEqual( + fs.readFileSync('CHANGELOG.md', 'utf8'), + `27.0.0-alpha.1.0.0
\n` + + `27.0.0-alpha.0.0.0
\n` + ); + }); +}); + +describe('prepare_release: utils.updateNodeVersionForNextMajor', () => { + it('moves the default branch to the next major release line', () => { + assert.strictEqual( + utils.updateNodeVersionForNextMajor(nodeVersionH({ + major: 27, minor: 0, patch: 0, isRelease: 1, alpha: { major: 0, minor: 0, patch: 0 } + }), 28), + nodeVersionH({ major: 28, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 0 } }) + ); + }); + + it('resets the alpha macros, or adds them if missing', () => { + const expected = + nodeVersionH({ major: 28, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 0 } }); + assert.strictEqual(utils.updateNodeVersionForNextMajor(nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 3, minor: 2, patch: 1 } + }), 28), expected); + assert.strictEqual(utils.updateNodeVersionForNextMajor(nodeVersionH({ + major: 27, minor: 0, patch: 0, isRelease: 1 + }), 28), expected); + }); +}); + +describe('prepare_release: utils.incrementVersion', () => { + it('increments Alpha and other versions', () => { + assert.strictEqual(utils.incrementVersion('27.0.0-alpha.1.2.3', 'patch'), '27.0.0-alpha.1.2.4'); + assert.strictEqual(utils.incrementVersion('27.0.0-alpha.1.2.3', 'minor'), '27.0.0-alpha.1.3.0'); + assert.strictEqual(utils.incrementVersion('26.12.1', 'patch'), '26.12.2'); + assert.strictEqual(utils.incrementVersion('26.12.1', 'minor'), '26.13.0'); + }); +}); + +const commitLine = (sha, title, labels = '') => + `* \\[[\`${sha}\`](https://github.com/nodejs/node/commit/${sha})] - ` + + `${labels ? `**(${labels})** ` : ''}${title} (Author) [#1](https://github.com/nodejs/node/pull/1)`; + +describe('prepare_release: filterAlphaReleaseCommits', () => { + it('removes Alpha release and working on commits', () => { + const commits = [ + commitLine('aaaaaaaaaa', 'Working on v27.0.0-alpha.0.0.1'), + commitLine('bbbbbbbbbb', '2026-10-28, Version 27.0.0-alpha.0.0.0 (Alpha)'), + commitLine('cccccccccc', '**doc**: fix typo') + ].join('\n'); + assert.strictEqual( + filterAlphaReleaseCommits(commits), commitLine('cccccccccc', '**doc**: fix typo')); + }); +}); + +describe('prepare_release: parseReverts', () => { + it('parses the reverted commits from git log', () => { + const log = 'abc1230000\x1fThis reverts commit def4560000111.\n\nPR-URL: x\x1e\n' + + 'fff0000000\x1fRefs: y\x1e\n'; + assert.deepStrictEqual(parseReverts(log), [['abc1230000', 'def4560000111']]); + }); +}); + +describe('prepare_release: formatMajorReleaseCommits', () => { + it('groups the commits by semver label', () => { + const commits = [ + commitLine('1111111111', '**lib**: add feature', 'SEMVER-MINOR'), + commitLine('2222222222', '**lib**: break things', 'SEMVER-MAJOR'), + commitLine('3333333333', '**doc**: fix typo'), + '' + ].join('\n'); + assert.strictEqual(formatMajorReleaseCommits(commits, []), [ + '### Semver-Major Commits', + '', + commitLine('2222222222', '**lib**: break things', 'SEMVER-MAJOR'), + '', + '### Semver-Minor Commits', + '', + commitLine('1111111111', '**lib**: add feature', 'SEMVER-MINOR'), + '', + '### Semver-Patch Commits', + '', + commitLine('3333333333', '**doc**: fix typo'), + '' + ].join('\n')); + }); + + it('drops reverted commits along with their reverts, keeping relands', () => { + const commits = [ + commitLine('4444444444', '_**Revert**_ "**lib**: revert the revert"'), + commitLine('3333333333', '_**Revert**_ "**lib**: break things"'), + commitLine('2222222222', '**lib**: break things', 'SEMVER-MAJOR'), + commitLine('1111111111', '_**Revert**_ "**src**: not in this release line"'), + commitLine('5555555555', '**http**: drop me', 'SEMVER-MAJOR'), + commitLine('6666666666', '_**Revert**_ "**http**: drop me"') + ].join('\n'); + const reverts = [ + ['6666666666aaaa', '5555555555bbbb'], + ['4444444444cccc', '3333333333dddd'], + ['3333333333dddd', '2222222222eeee'], + ['1111111111ffff', '0000000000aaaa'] + ]; + assert.strictEqual(formatMajorReleaseCommits(commits, reverts), [ + '### Semver-Major Commits', + '', + commitLine('2222222222', '**lib**: break things', 'SEMVER-MAJOR'), + '', + '### Semver-Patch Commits', + '', + commitLine('1111111111', '_**Revert**_ "**src**: not in this release line"'), + '' + ].join('\n')); + }); +}); + +describe('prepare_release: first release of a release line', () => { + const firstAlphaFiles = { + 'src/node_version.h': nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 0, minor: 0, patch: 0 } + }) + }; + + it('calculates the first Alpha version without a changelog', async(t) => { + nodeCheckout(t, firstAlphaFiles); + const release = releasePreparation({ + getCurrentBranch: () => 'v27.x-staging', + getLastRelease() { throw new Error('No release to compare with'); } + }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isFirstRelease, true); + assert.strictEqual(release.isAlpha, true); + assert.strictEqual(release.newVersion, '27.0.0-alpha.0.0.0'); + assert.deepStrictEqual(release.versionComponents, { major: 27, minor: 0, patch: 0 }); + assert.deepStrictEqual(release.alphaVersionComponents, { major: 0, minor: 0, patch: 0 }); + }); + + it('accepts the first Alpha version as --newVersion', async(t) => { + nodeCheckout(t, firstAlphaFiles); + const release = releasePreparation({ + newVersion: '27.0.0-alpha.0.0.0', + getLastRelease() { throw new Error('No release to compare with'); } + }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isFirstRelease, true); + assert.strictEqual(release.newVersion, '27.0.0-alpha.0.0.0'); + }); + + const otherVersions = [ + '* Other Versions', + ' * [25.x](CHANGELOG_V25.md)', + ' * [Archive](CHANGELOG_ARCHIVE.md)', + '' + ].join('\n'); + + it('creates CHANGELOG_V27.md and links it from the other changelogs', async(t) => { + nodeCheckout(t, { + 'doc/changelogs/CHANGELOG_V26.md': `# Node.js 26 ChangeLog\n\n${otherVersions}\n`, + 'doc/changelogs/CHANGELOG_ARCHIVE.md': `# Archive\n\n${otherVersions}` + }); + const majorCommits = + '### Semver-Major Commits\n\n* major\n\n### Semver-Patch Commits\n\n* patch\n'; + const release = releasePreparation({ + isAlpha: true, + isFirstRelease: true, + date: '2026-10-28', + newVersion: '27.0.0-alpha.0.0.0', + versionComponents: { major: 27, minor: 0, patch: 0 }, + getBranchDiff: async() => '* major\n', + getMajorReleaseCommits: async() => majorCommits, + getLastRef() { throw new Error('No release to compare with'); } + }); + await release.updateMajorChangelog(); + assert.strictEqual(fs.readFileSync('doc/changelogs/CHANGELOG_V27.md', 'utf8'), [ + '# Node.js 27 ChangeLog', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '
Alpha
', + '27.0.0-alpha.0.0.0
', + '
', + '', + '* Other Versions', + ' * [26.x](CHANGELOG_V26.md)', + ' * [25.x](CHANGELOG_V25.md)', + ' * [Archive](CHANGELOG_ARCHIVE.md)', + '', + '', + '', + '## 2026-10-28, Version 27.0.0-alpha.0.0.0 (Alpha), @RafaelGSS', + '', + '### Notable Changes', + '', + '* major', + '', + '### Semver-Major Commits', + '', + '* major', + '', + '### Semver-Patch Commits', + '', + '* patch', + '' + ].join('\n')); + for (const file of ['CHANGELOG_V26.md', 'CHANGELOG_ARCHIVE.md']) { + assert.match( + fs.readFileSync(`doc/changelogs/${file}`, 'utf8'), + /\n\* Other Versions\n {2}\* \[27\.x\]\(CHANGELOG_V27\.md\)\n {2}\* \[25\.x\]/ + ); + } + }); + + it('adds the release line to CHANGELOG.md', async(t) => { + const link = 'doc/changelogs/CHANGELOG_V26.md'; + nodeCheckout(t, { + 'CHANGELOG.md': [ + '# Node.js Changelog', + '', + `* [Node.js 26](${link}) **Current**`, + '', + '', + '', + ` `, + '', + '', + ' ', + '' + ].join('\n') + }); + const release = releasePreparation({ + isAlpha: true, + isFirstRelease: true, + newVersion: '27.0.0-alpha.0.0.0', + versionComponents: { major: 27, minor: 0, patch: 0 }, + getLastRef() { throw new Error('No release to compare with'); } + }); + await release.updateMainChangelog(); + const newLink = 'doc/changelogs/CHANGELOG_V27.md'; + assert.strictEqual(fs.readFileSync('CHANGELOG.md', 'utf8'), [ + '# Node.js Changelog', + '', + `* [Node.js 27](${newLink}) **Alpha**`, + `* [Node.js 26](${link}) **Current**`, + '', + '
26 (Current)
', + `26.12.0
`, + '
', + '', + ` `, + ` `, + '', + '', + ' ', + ' ', + '' + ].join('\n')); + }); +}); + +describe('prepare_release: release line out of Alpha', () => { + const alphaLineFiles = { + 'src/node_version.h': nodeVersionH({ + major: 27, minor: 0, patch: 0, alpha: { major: 2, minor: 0, patch: 1 } + }), + 'doc/changelogs/CHANGELOG_V27.md': changelogV27 + }; + + it('rejects --startLTS on an Alpha release line', async(t) => { + nodeCheckout(t, alphaLineFiles); + for (const props of [ + { getCurrentBranch: () => 'v27.x-staging' }, + { newVersion: '27.0.0' }, + { newVersion: '27.0.0-alpha.3.0.0' } + ]) { + const release = releasePreparation({ isLTSTransition: true, ...props }); + await assert.rejects(release.prepareLocalBranch(), /Cannot use --startLTS on an Alpha/); + } + }); + + it('marks a non-Alpha version on an Alpha line as out of Alpha', async(t) => { + nodeCheckout(t, alphaLineFiles); + const release = releasePreparation({ newVersion: '27.0.0' }); + await release.prepareLocalBranch(); + assert.strictEqual(release.isOutOfAlpha, true); + assert.strictEqual(release.isFirstRelease, false); + }); + + const outOfAlpha = () => releasePreparation({ + isOutOfAlpha: true, + date: '2027-04-20', + newVersion: '27.0.0', + versionComponents: { major: 27, minor: 0, patch: 0 }, + getChangelog: () => '', + getBranchDiff: async() => '' + }); + + it('relabels CHANGELOG_V27.md as Current', async(t) => { + nodeCheckout(t, { 'doc/changelogs/CHANGELOG_V27.md': changelogV27 }); + await outOfAlpha().updateMajorChangelog(); + const changelog = fs.readFileSync('doc/changelogs/CHANGELOG_V27.md', 'utf8'); + assert.ok(changelog.includes('')); + assert.ok(!changelog.includes('')); + assert.ok(changelog.includes('## 2027-04-20, Version 27.0.0 (Current), @RafaelGSS')); + }); + + it('relabels the release line as Current in CHANGELOG.md', async(t) => { + const link = 'doc/changelogs/CHANGELOG_V27.md'; + nodeCheckout(t, { + 'CHANGELOG.md': [ + `* [Node.js 27](${link}) **Alpha**`, + ` `, + `27.0.0-alpha.0.0.0
`, + '' + ].join('\n') + }); + await outOfAlpha().updateMainChangelog(); + assert.strictEqual(fs.readFileSync('CHANGELOG.md', 'utf8'), [ + `* [Node.js 27](${link}) **Current**`, + ` `, + `27.0.0
`, + `27.0.0-alpha.0.0.0
`, + '' + ].join('\n')); + }); +}); diff --git a/test/unit/promote_release.test.js b/test/unit/promote_release.test.js new file mode 100644 index 00000000..882944d0 --- /dev/null +++ b/test/unit/promote_release.test.js @@ -0,0 +1,196 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +import ReleasePromotion from '../../lib/promote_release.js'; +import { updateNodeVersionForNextRelease } from '../../lib/release/utils.js'; + +const today = new Date().toISOString().slice(0, 10); + +function releasePromotion() { + const errors = []; + const release = Object.assign(Object.create(ReleasePromotion.prototype), { + gpgSign: [], + cli: { + error: (message) => errors.push(message), + warn: () => {}, + info: () => {}, + separator: () => {}, + prompt: async() => false + } + }); + return { release, errors }; +} + +describe('promote_release: validateReleaseCommit', () => { + it('parses an Alpha release commit', async() => { + const { release } = releasePromotion(); + assert.deepStrictEqual( + await release.validateReleaseCommit(`${today}, Version 27.0.0-alpha.0.0.0 (Alpha)`), + { + date: today, + version: '27.0.0-alpha.0.0.0', + stagingBranch: 'v27.x-staging', + versionComponents: { major: 27, minor: 0, patch: 0 }, + alphaVersionComponents: { major: 0, minor: 0, patch: 0 }, + isAlpha: true + } + ); + }); + + it('parses Current and LTS release commits', async() => { + const { release } = releasePromotion(); + const current = await release.validateReleaseCommit(`${today}, Version 26.12.1 (Current)`); + assert.strictEqual(current.version, '26.12.1'); + assert.strictEqual(current.alphaVersionComponents, null); + assert.strictEqual(current.isAlpha, undefined); + assert.strictEqual(current.isLTS, undefined); + + const lts = await release.validateReleaseCommit(`${today}, Version 24.11.0 'Krypton' (LTS)`); + assert.strictEqual(lts.version, '24.11.0'); + assert.strictEqual(lts.isAlpha, undefined); + assert.strictEqual(lts.isLTS, true); + assert.strictEqual(lts.ltsCodename, 'Krypton'); + }); + + it('rejects mismatched Alpha versions and release types', async() => { + for (const title of [ + `${today}, Version 27.0.0 (Alpha)`, + `${today}, Version 27.0.0-alpha.1 (Alpha)`, + `${today}, Version 27.0.0-alpha.0.0.0 (Current)`, + `${today}, Version 27.0.0-alpha.0.0.0 'Krypton' (LTS)` + ]) { + const { release, errors } = releasePromotion(); + await assert.rejects(release.validateReleaseCommit(title), /Aborted/, title); + assert.strictEqual(errors.length, 1, title); + } + }); +}); + +describe('promote_release: updateNodeVersionForNextRelease', () => { + const header = (patch, isRelease, alphaPatch) => [ + '#define NODE_MAJOR_VERSION 27', + '#define NODE_MINOR_VERSION 0', + `#define NODE_PATCH_VERSION ${patch}`, + '', + `#define NODE_VERSION_IS_RELEASE ${isRelease}`, + '', + '#define NODE_ALPHA_MAJOR_VERSION 1', + '#define NODE_ALPHA_MINOR_VERSION 0', + `#define NODE_ALPHA_PATCH_VERSION ${alphaPatch}`, + '' + ].join('\n'); + + it('bumps NODE_ALPHA_PATCH_VERSION for Alpha releases', () => { + assert.strictEqual( + updateNodeVersionForNextRelease(header(0, 1, 9), { + versionComponents: { major: 27, minor: 0, patch: 0 }, + alphaVersionComponents: { major: 1, minor: 0, patch: 9 } + }), + header(0, 0, 10) + ); + }); + + it('bumps NODE_PATCH_VERSION for other releases', () => { + const current = [ + '#define NODE_MAJOR_VERSION 26', + '#define NODE_MINOR_VERSION 12', + '#define NODE_PATCH_VERSION 9', + '', + '#define NODE_VERSION_IS_RELEASE 1', + '' + ].join('\n'); + assert.strictEqual( + updateNodeVersionForNextRelease(current, { + versionComponents: { major: 26, minor: 12, patch: 9 } + }), + current + .replace('NODE_PATCH_VERSION 9', 'NODE_PATCH_VERSION 10') + .replace('NODE_VERSION_IS_RELEASE 1', 'NODE_VERSION_IS_RELEASE 0') + ); + }); +}); + +function git(...args) { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +function nodeVersionH(major, isRelease, alpha) { + return [ + `#define NODE_MAJOR_VERSION ${major}`, + '#define NODE_MINOR_VERSION 0', + '#define NODE_PATCH_VERSION 0', + '', + `#define NODE_VERSION_IS_RELEASE ${isRelease}`, + '', + ...(alpha + ? [ + `#define NODE_ALPHA_MAJOR_VERSION ${alpha[0]}`, + `#define NODE_ALPHA_MINOR_VERSION ${alpha[1]}`, + `#define NODE_ALPHA_PATCH_VERSION ${alpha[2]}`, + '' + ] + : []), + '#ifndef NODE_STRINGIFY', + '' + ].join('\n'); +} + +function repository(t, releaseTitle, releaseNodeVersionH) { + const previous = process.cwd(); + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ncu-promote-release-')); + t.after(() => { + process.chdir(previous); + fs.rmSync(directory, { recursive: true, force: true }); + }); + process.chdir(directory); + git('init', '-b', 'main'); + git('config', 'user.name', 'Test User'); + git('config', 'user.email', 'test@example.com'); + git('config', 'commit.gpgsign', 'false'); + git('config', 'core.hooksPath', path.join(directory, 'no-hooks')); + fs.mkdirSync('src'); + fs.writeFileSync('src/node_version.h', nodeVersionH(27, 0, [0, 0, 0])); + fs.writeFileSync('CHANGELOG.md', '# Changelog\n'); + git('add', '.'); + git('commit', '-m', 'Initial commit'); + git('checkout', '-b', 'v27.x'); + fs.writeFileSync('src/node_version.h', releaseNodeVersionH); + fs.writeFileSync('CHANGELOG.md', `# Changelog\n\n${releaseTitle}\n`); + git('commit', '-am', releaseTitle); + const releaseCommitSha = git('rev-parse', 'HEAD'); + git('checkout', 'main'); + return releaseCommitSha; +} + +describe('promote_release: cherryPickReleaseCommit', { concurrency: false }, () => { + it('keeps node_version.h of the default branch for Alpha releases', async(t) => { + const title = `${today}, Version 27.0.0-alpha.1.0.0 (Alpha)`; + const sha = repository(t, title, nodeVersionH(27, 1, [1, 0, 0])); + const { release } = releasePromotion(); + await release.cherryPickReleaseCommit(sha, { + isAlpha: true, versionComponents: { major: 27, minor: 0, patch: 0 } + }); + assert.strictEqual(git('log', '-1', '--format=%s'), title); + assert.strictEqual(git('show', '--format=', '--name-only', 'HEAD'), 'CHANGELOG.md'); + assert.strictEqual( + fs.readFileSync('src/node_version.h', 'utf8'), nodeVersionH(27, 0, [0, 0, 0])); + }); + + it('moves the default branch to the next major after a major release', async(t) => { + const title = `${today}, Version 27.0.0 (Current)`; + const sha = repository(t, title, nodeVersionH(27, 1)); + const { release } = releasePromotion(); + await release.cherryPickReleaseCommit(sha, { + versionComponents: { major: 27, minor: 0, patch: 0 } + }); + assert.strictEqual(git('log', '-1', '--format=%s'), title); + assert.strictEqual( + git('show', '--format=', '--name-only', 'HEAD'), 'CHANGELOG.md\nsrc/node_version.h'); + assert.strictEqual( + fs.readFileSync('src/node_version.h', 'utf8'), nodeVersionH(28, 0, [0, 0, 0])); + }); +}); diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index 9947d9c8..a5447714 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -343,6 +343,30 @@ describe('security_release: CVE request versions', () => { ]); }); + it('offers the next Alpha versions on an Alpha release line', async() => { + const radioChoices = []; + const release = new UpdateSecurityRelease({ + prompt(message, options) { + assert.strictEqual(options.defaultAnswer, ''); + return '27.0.0-alpha.1.2.3'; + }, + promptRadio(message, choices, options) { + radioChoices.push(choices, options.defaultAnswer); + return choices[0]; + } + }); + + assert.deepStrictEqual( + (await release.calculateVersions(['27.x'], [{ major: 26, version: '26.12.0' }], [])) + .patchedVersions, + ['27.0.0-alpha.1.2.4'] + ); + assert.deepStrictEqual(radioChoices, [ + ['27.0.0-alpha.1.2.4', '27.0.0-alpha.1.3.0'], + '27.0.0-alpha.1.2.4' + ]); + }); + it('applies successful CVE updates once after requests finish', async() => { const messages = []; const prompts = [];
27 (Alpha)26 (Current)
', + `27.0.0-alpha.0.0.0
`, + '
', + `26.12.0
`, + '
CurrentAlpha27 (Alpha)27 (Current)