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',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '## 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',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '* 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**`,
+ '',
+ '',
+ '',
+ ` | 26 (Current) | `,
+ ' ',
+ '',
+ ' ',
+ `26.12.0 `,
+ ' | ',
+ ''
+ ].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**`,
+ '',
+ '',
+ '',
+ ` | 27 (Alpha) | `,
+ ` 26 (Current) | `,
+ ' ',
+ '',
+ ' ',
+ `27.0.0-alpha.0.0.0 `,
+ ' | ',
+ ' ',
+ `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('Current | '));
+ assert.ok(!changelog.includes('Alpha | '));
+ 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 (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 (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 = [];
|