import path from 'node:path';
import { promises as fs, existsSync, readFileSync } from 'node:fs';
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';
import Session from './session.js';
import {
getAffectedVersionLines,
getDependencyUpdates,
SEVERITY_RANKS
} from './security-release/security-release.js';
const isWindows = process.platform === 'win32';
// Parse a GitHub Pull Request URL into its owner, repo and number.
// Returns null when the URL does not look like a PR URL.
export function parsePullRequestURL(url) {
const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(url || '');
if (!match) return null;
return { owner: match[1], repo: match[2], number: Number(match[3]) };
}
// Resolve the PR URL that fixes a given release line from a report or
// dependency `affectedVersions` entry.
// Supports the current schema, where `affectedVersions` maps a release line to
// its PR URL (e.g. `{ '22.x': 'https://.../pull/1' }`), and the legacy schema,
// where `affectedVersions` is an array of lines paired with a single `prURL`.
export function getPullRequestURLForLine(affectedVersions, line, legacyPrURL) {
if (
affectedVersions &&
typeof affectedVersions === 'object' &&
!Array.isArray(affectedVersions)
) {
return affectedVersions[line] || null;
}
if (Array.isArray(affectedVersions) && legacyPrURL) {
return getAffectedVersionLines(affectedVersions).includes(line)
? legacyPrURL
: null;
}
return null;
}
// Format the notable changes of a security release, one entry per commit:
// `* (CVE-ID) subsystem: title (Author) – Severity`, sorted from highest to
// lowest severity, then by CVE-ID. Commits without a CVE-ID trailer
// (e.g. dependency updates) are listed last.
export function formatSecurityNotableChanges(commits, severityByCVE) {
const rank = rating => SEVERITY_RANKS.indexOf((rating || '').toUpperCase());
const entries = commits
.filter(({ subject }) => subject)
.map(({ subject, author, cveIds = [] }) => ({
subject,
author,
cveIds,
rating: cveIds.map(id => severityByCVE.get(id)).find(Boolean) || ''
}))
.sort((a, b) => (rank(b.rating) - rank(a.rating)) ||
(b.cveIds.length - a.cveIds.length) ||
(a.cveIds[0] || '').localeCompare(b.cveIds[0] || '', 'en', { numeric: true }));
const lines = entries.map(({ subject, author, cveIds, rating }) => {
const cve = cveIds.length ? `(${cveIds.join(', ')}) ` : '';
const severity = rating
? ` – ${rating[0].toUpperCase()}${rating.slice(1).toLowerCase()}`
: '';
return `* ${cve}${subject} (${author})${severity}`;
});
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 formatMajorReleaseCommits(commits) {
const sections = { 'Semver-Major': [], 'Semver-Minor': [], 'Semver-Patch': [] };
for (const line of commits.split('\n')) {
if (!line.startsWith('* ')) continue;
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);
// argv.security can be either:
// - true (boolean) if --security was used without parameter
// - 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;
this.defaultReleaseDate = argv.releaseDate ?? new Date().toISOString().slice(0, 10);
this.ltsCodename = '';
this.date = '';
this.newVersion = argv.newVersion;
}
get branch() {
return this.isSecurityRelease ? this.releaseBranch : this.stagingBranch;
}
// Resolve the path to the security release vulnerabilities.json.
// `--security` may point either directly at a vulnerabilities.json file or at
// the root of the security-release repository, in which case the well-known
// sub-path is appended.
getVulnerabilitiesJSONPath() {
if (!this.securityReleaseRepo) return null;
if (this.securityReleaseRepo.endsWith('.json')) {
return this.securityReleaseRepo;
}
return path.join(
this.securityReleaseRepo,
'security-release',
'next-security-release',
'vulnerabilities.json'
);
}
loadVulnerabilitiesJSON() {
const { cli } = this;
const vulnPath = this.getVulnerabilitiesJSONPath();
if (!vulnPath) {
cli.error('No vulnerabilities.json path provided. Pass the path to ' +
'vulnerabilities.json (or the security-release repository) via ' +
'`--security=<path>`.');
return null;
}
if (!existsSync(vulnPath)) {
cli.error(`vulnerabilities.json not found at ${vulnPath}.`);
return null;
}
try {
cli.startSpinner(`Reading vulnerabilities.json from ${vulnPath}..`);
const vulnData = JSON.parse(readFileSync(vulnPath, 'utf-8'));
cli.stopSpinner(`Done reading vulnerabilities.json from ${vulnPath}`);
return vulnData;
} catch (err) {
cli.error(`Failed to read vulnerabilities.json: ${err.message}`);
return null;
}
}
// Build the list of PRs to cherry-pick for a given release line from both the
// reports and the dependency updates, using their `affectedVersions` mapping.
getSecurityCherryPickTargets(vulnData, line) {
const targets = [];
for (const report of vulnData.reports ?? []) {
const url = getPullRequestURLForLine(
report.affectedVersions, line, report.prURL);
if (url) {
targets.push({
url,
cveIds: report.cveIds?.length ? report.cveIds : null,
label: report.title || report.id
});
}
}
for (const dep of getDependencyUpdates(vulnData.dependencies)) {
const url = getPullRequestURLForLine(
dep.affectedVersions, line, dep.prURL);
if (url) {
targets.push({
url,
cveIds: null,
label: `dependency: ${dep.name}`,
isDependency: true
});
}
}
return targets;
}
upstreamForPR({ owner, repo }) {
if (owner === 'nodejs-private') {
return `https://${this.username}:${this.config.token}` +
`@github.com/${owner}/${repo}.git`;
}
return `https://github.com/${owner}/${repo}.git`;
}
async cherryPickSecurityPRs() {
const { cli } = this;
const line = `${this.versionComponents.major}.x`;
const vulnData = this.loadVulnerabilitiesJSON();
if (!vulnData) {
cli.error('Cannot proceed with a security release without a valid ' +
'vulnerabilities.json.');
return false;
}
const targets = this.getSecurityCherryPickTargets(vulnData, line);
if (targets.length === 0) {
cli.warn(`No PRs found in vulnerabilities.json for release line ${line}.`);
return false;
}
cli.info(`Cherry-picking ${targets.length} PR(s) for ${line} from ` +
'vulnerabilities.json');
let amendAll = false;
for (const target of targets) {
const pr = parsePullRequestURL(target.url);
if (!pr) {
cli.warn(`Skipping invalid PR URL for ${target.label}: ${target.url}`);
continue;
}
if (!amendAll) {
const answer = await cli.promptSelect(
`Would you like to amend ${target.label} (${target.url}) to the proposal?`,
[
{ name: 'Yes', value: 'yes' },
{ name: 'No, skip this PR', value: 'no' },
{
name: 'Yes to all remaining PRs (no further prompts)',
value: 'all'
}
]);
if (answer === 'no') continue;
amendAll = answer === 'all';
}
if (!target.cveIds && !target.isDependency) {
cli.warn(`No CVE-IDs found in vulnerabilities.json for ${target.url}`);
}
cli.info(`Cherry-picking ${target.label} (${target.url})`);
const cp = new CherryPick(pr.number, this.dir, cli, {
owner: pr.owner,
repo: pr.repo,
gpgSign: this.gpgSign,
upstream: this.upstreamForPR(pr),
lint: false,
includeCVE: !target.isDependency,
cveIds: target.cveIds,
promptAmend: false,
skipMessagePrompt: amendAll
});
const success = await cp.start();
if (!success) {
cli.warn(`The cherry-pick has failed. PR: ${target.url}`);
return false;
}
}
return true;
}
async prepare() {
const { cli, newVersion, versionComponents, isSecurityRelease } = this;
if (isSecurityRelease) {
this.config.owner = 'nodejs-private';
this.config.repo = 'node-private';
}
const runBranchDiff = await cli.prompt(
'Do you want to check if any additional commits could be backported ' +
'(recommended except for Maintenance releases)?',
{ defaultAnswer: this.runBranchDiff });
if (runBranchDiff) {
if (isSecurityRelease) {
const success = await this.cherryPickSecurityPRs();
if (!success) {
cli.error('Aborting security release preparation.');
return;
}
} else {
// TODO: UPDATE re-use
// Check the branch diff to determine if the releaser
// wants to backport any more commits before proceeding.
cli.startSpinner('Fetching branch-diff');
const raw = await this.getBranchDiff({
onlyNotableChanges: false,
comparisonBranch: newVersion
});
const diff = raw.split('*');
cli.stopSpinner('Got branch diff');
const outstandingCommits = diff.length - 1;
if (outstandingCommits !== 0) {
const proceed = await cli.prompt(
`There are ${outstandingCommits} commits that may be ` +
`backported to ${this.stagingBranch} - do you still want to proceed?`,
{ defaultAnswer: false });
if (!proceed) {
const seeDiff = await cli.prompt(
'Do you want to see the branch diff?');
if (seeDiff) cli.log(raw);
return;
}
}
}
}
// Create new proposal branch.
cli.startSpinner(`Switching to proposal branch for ${newVersion}`);
await this.createProposalBranch();
cli.stopSpinner(`Switched to proposal branch for ${newVersion}`);
if (this.isLTSTransition) {
// For releases transitioning into LTS, fetch the new code name.
this.ltsCodename = await this.getLTSCodename(versionComponents.major);
// Update test for new LTS code name.
const testFile = path.resolve(
'test',
'parallel',
'test-process-release.js'
);
cli.startSpinner(`Updating ${testFile}`);
await this.updateTestProcessRelease(testFile);
cli.stopSpinner(`Updating ${testFile}`);
}
// Update version and release info in src/node_version.h.
cli.startSpinner(`Updating 'src/node_version.h' for ${newVersion}`);
await this.updateNodeVersion();
cli.stopSpinner(`Updated 'src/node_version.h' for ${newVersion}`);
// Check whether to update NODE_MODULE_VERSION.
const isSemverMajor =
(this.isAlpha ? this.alphaVersionComponents : versionComponents).minor === 0;
if (isSemverMajor) {
const shouldUpdateNodeModuleVersion = await cli.prompt(
'Update NODE_MODULE_VERSION?', { defaultAnswer: false });
if (shouldUpdateNodeModuleVersion) {
const variant = await cli.prompt(
'Specify variant (ex. \'v8_7.9\') for new NODE_MODULE_VERSION:',
{ questionType: 'input', noSeparator: true });
const versions = await cli.prompt(
'Specify versions (ex. \'14.0.0-pre\') for new NODE_MODULE_VERSION:',
{ questionType: 'input', noSeparator: true });
this.updateNodeModuleVersion('node', variant, versions);
}
}
// Update any REPLACEME tags in the docs.
cli.startSpinner('Updating REPLACEME items in docs');
await this.updateREPLACEMEs();
cli.stopSpinner('Updated REPLACEME items in docs');
// Fetch date to use in release commit & changelogs.
this.date = await cli.prompt('Enter release date in YYYY-MM-DD format:',
{ questionType: 'input', defaultAnswer: this.defaultReleaseDate });
cli.startSpinner('Updating CHANGELOG.md');
await this.updateMainChangelog();
cli.stopSpinner('Updated CHANGELOG.md');
cli.startSpinner(`Updating CHANGELOG_V${versionComponents.major}.md`);
await this.updateMajorChangelog();
cli.stopSpinner(`Updated CHANGELOG_V${versionComponents.major}.md`);
// Create release commit.
const shouldCreateReleaseCommit = await cli.prompt(
'Create release commit?');
if (!shouldCreateReleaseCommit) {
cli.warn(`Aborting \`git node release\` for version ${newVersion}`);
return;
}
// Proceed with release only after the releaser has amended
// it to their liking.
const createDefaultCommit = await this.createReleaseCommit();
if (!createDefaultCommit) {
const lastCommitSha = runSync('git', ['rev-parse', '--short', 'HEAD']);
cli.warn(`Please manually edit commit ${lastCommitSha} by running ` +
'`git commit --amend` before proceeding.');
await cli.prompt('Finished editing the release commit?');
}
cli.separator();
cli.ok(`Release preparation for ${newVersion} complete.\n`);
cli.info(
'To finish the release proposal, run: \n' +
` $ git push -u ${this.upstream} v${newVersion}-proposal\n` +
'Finally, proceed to Jenkins and begin the following CI jobs:\n' +
' * https://ci.nodejs.org/job/node-test-pull-request/\n' +
' * https://ci.nodejs.org/job/citgm-smoker/');
cli.info(
'If this release has deps/v8 changes, you\'ll also need to run:\n' +
' * https://ci.nodejs.org/job/node-test-commit-v8-linux/'
);
}
get owner() {
return this.config.owner || 'nodejs';
}
get repo() {
return this.config.repo || 'node';
}
get upstream() {
return this.config.upstream;
}
get username() {
return this.config.username;
}
warnForMissing() {
const { cli, upstream, username } = this;
const missing = !username || !upstream;
if (!upstream) {
cli.warn('You have not told git-node the remote you want to sync with.');
cli.separator();
cli.info(
'For example, if your remote pointing to nodejs/node is' +
' `remote-upstream`, you can run:\n\n' +
' $ ncu-config set upstream remote-upstream');
cli.separator();
cli.setExitCode(1);
}
if (!username) {
cli.warn('You have not told git-node your username.');
cli.separator();
cli.info(
'To fix this, you can run: ' +
' $ ncu-config set username <your_username>');
cli.separator();
cli.setExitCode(1);
}
return missing;
}
async calculateNewVersion({ tagName, major, minor, patch }) {
const changelog = this.getChangelog(tagName);
const newVersion = { major, minor, patch };
if (changelog.includes('SEMVER-MAJOR')) {
newVersion.major++;
newVersion.minor = 0;
newVersion.patch = 0;
} else if (changelog.includes('SEMVER-MINOR') || this.isLTSTransition) {
newVersion.minor++;
newVersion.patch = 0;
} else {
newVersion.patch++;
}
return newVersion;
}
getCurrentBranch() {
return runSync('git', ['rev-parse', '--abbrev-ref', 'HEAD']).trim();
}
getLastRef(tagName) {
if (!tagName) {
return runSync('git', ['describe', '--abbrev=0', '--tags']).trim();
}
try {
runSync('git', ['rev-parse', tagName]);
} catch {
this.cli.error(`Error parsing git ref ${tagName}`);
this.cli.startSpinner('Attempting fetching it as a tag\n');
try {
runSync('git', ['fetch', this.upstream, 'tag', '-n', tagName]);
} catch (e) {
this.cli.error(`${e.message}\nRun: git remote update`);
process.exit(1);
}
this.cli.stopSpinner(`Tag fetched: ${tagName}`);
}
try {
if (runSync('git', ['describe', '--abbrev=0', '--tags']).trim() !== tagName) {
this.cli.warn(`${tagName} is not the closest tag`);
}
} catch {
this.cli.startSpinner(`${tagName} is unreachable from the current HEAD`);
runSync('git', ['fetch', '--shallow-exclude', tagName, this.upstream, this.branch]);
runSync('git', ['fetch', '--deepen=1', this.upstream, this.branch]);
this.cli.stopSpinner('Local clone unshallowed');
}
return tagName;
}
getChangelog(tagName) {
const changelogMaker = new URL(
'../node_modules/.bin/changelog-maker' + (isWindows ? '.cmd' : ''),
import.meta.url
);
return filterAlphaReleaseCommits(runSync(changelogMaker, [
'--group',
'--markdown',
'--filter-release',
'--start-ref',
this.getLastRef(tagName)
])).trim();
}
async getLTSCodename(version) {
const { cli } = this;
return await cli.prompt(
'Enter the LTS code name for this release line\n' +
'(Refs: https://github.com/nodejs/Release/blob/main/CODENAMES.md):',
{ questionType: 'input', noSeparator: true, defaultAnswer: '' }
);
}
async updateREPLACEMEs() {
const { newVersion } = this;
const promises = [];
for await (const file of fs.glob('doc/api/*.md')) {
promises.push(fs.open(file, 'r+').then(async fd => {
try {
const fileContent = await fd.readFile('utf-8');
if (fileContent.includes('REPLACEME')) {
const { bytesWritten } =
await fd.write(fileContent.replaceAll('REPLACEME', `v${newVersion}`), 0, 'utf-8');
await fd.truncate(bytesWritten);
}
// eslint-disable-next-line promise/always-return
} finally {
// TODO: replace with `using` syntax when dropping support for Node.js 22
await fd.close();
}
}));
}
await Promise.all(promises);
}
async updateMainChangelog() {
const {
date,
isAlpha,
isFirstRelease,
isLTSTransition,
isOutOfAlpha,
versionComponents,
newVersion
} = this;
const mainChangelogPath = path.resolve('CHANGELOG.md');
const data = await fs.readFile(mainChangelogPath, 'utf8');
const arr = data.split('\n');
const major = versionComponents.major;
const hrefLink = `doc/changelogs/CHANGELOG_V${major}.md`;
const newRefLink = `<a href="${hrefLink}#${newVersion}">${newVersion}</a>`;
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(' <th title=',
` <th title="${releaseType}"><a href="${hrefLink}">${major}</a> (${releaseType})</th>`);
insertBefore(' <td valign="top">',
' <td valign="top">', `<b>${newRefLink}</b><br/>`, ' </td>');
await fs.writeFile(mainChangelogPath, arr.join('\n'));
return;
}
// Remove the leading 'v'.
const lastRef = this.getLastRef().substring(1);
const lastRefLink = `<a href="${hrefLink}#${lastRef}">${lastRef}</a>`;
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);
const eol = eolDate.toISOString().split('-').slice(0, 2).join('-');
arr[idx] = arr[idx].replace('**Current**', '**Long Term Support**');
arr[idx] = arr[idx].replace('"Current"', `"LTS Until ${eol}"`);
arr[idx] = arr[idx].replace('(Current)', '(LTS)');
} else if (arr[idx].includes('**Long Term Support**')) {
arr[idx] = arr[idx].replace(
'**Long Term Support**',
'Long Term Support'
);
}
}
if (arr[idx].includes(`<b>${lastRefLink}</b><br/>`)) {
arr.splice(idx, 1, `<b>${newRefLink}</b><br/>`, `${lastRefLink}<br/>`);
break;
}
}
await fs.writeFile(mainChangelogPath, arr.join('\n'));
}
async updateMajorChangelog() {
const {
versionComponents,
newVersion,
date,
isAlpha,
isFirstRelease,
isLTS,
isLTSTransition,
isOutOfAlpha,
isSecurityRelease,
ltsCodename,
username
} = this;
if (isFirstRelease) {
return this.createMajorChangelog();
}
const releaseInfo = formatReleaseInfo({ isAlpha, isLTS, ltsCodename });
const lastRef = this.getLastRef();
const majorChangelogPath = path.resolve(
'doc',
'changelogs',
`CHANGELOG_V${versionComponents.major}.md`
);
const data = await fs.readFile(majorChangelogPath, 'utf8');
const arr = data.split('\n');
const allCommits = this.getChangelog();
const notableChanges = isSecurityRelease
? await this.getSecurityNotableChanges()
: await this.getBranchDiff({
onlyNotableChanges: true,
format: 'markdown',
});
let releaseHeader = `## ${date}, Version ${newVersion}` +
` ${releaseInfo}, @${username}\n`;
if (isSecurityRelease) {
releaseHeader += '\nThis is a security release.\n';
}
const topHeader =
`<a href="#${lastRef.substring(1)}">${lastRef.substring(1)}</a>`;
const newHeader =
`<a href="#${newVersion}">${newVersion}</a><br/>`;
for (let idx = 0; idx < arr.length; idx++) {
if (isOutOfAlpha && arr[idx] === '<th>Alpha</th>') {
arr[idx] = '<th>Current</th>';
} else if (isLTSTransition && arr[idx].includes('<th>Current</th>')) {
// Create a new column for LTS.
arr.splice(idx, 0, `<th>LTS '${ltsCodename}'</th>`);
idx++;
} else if (arr[idx].includes(topHeader)) {
if (isLTSTransition) {
// New release needs to go into the new column for LTS.
const toAppend = [
newHeader,
'</td>',
arr[idx - 1]
];
arr.splice(idx, 0, ...toAppend);
idx += toAppend.length;
} else {
arr.splice(idx, 0, newHeader);
idx++;
}
} else if (arr[idx].includes(`<a id="${lastRef.substring(1)}"></a>`)) {
const toAppend = [];
toAppend.push(`<a id="${newVersion}"></a>\n`);
toAppend.push(releaseHeader);
toAppend.push('### Notable Changes\n');
if (isLTSTransition) {
toAppend.push(`${getStartLTSBlurb(this)}\n`);
}
if (notableChanges.trim()) {
toAppend.push(notableChanges);
}
toAppend.push('### Commits\n');
toAppend.push(allCommits);
toAppend.push('');
arr.splice(idx, 0, ...toAppend);
break;
}
}
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`,
'',
'<!--lint disable maximum-line-length no-literal-urls prohibited-strings-->',
'',
'<table>',
'<tr>',
`<th>${releaseType}</th>`,
'</tr>',
'<tr>',
'<td>',
`<a href="#${newVersion}">${newVersion}</a><br/>`,
'</td>',
'</tr>',
'</table>',
'',
'* Other Versions',
` * [${major - 1}.x](CHANGELOG_V${major - 1}.md)`,
`${otherVersions}`,
`<a id="${newVersion}"></a>`,
'',
`## ${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],
{ ignoreFailure: false });
await forceRunAsync('git', ['fetch', upstream, previousReleaseBranch],
{ ignoreFailure: 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'
]);
return formatMajorReleaseCommits(filterAlphaReleaseCommits(commits));
}
async createProposalBranch(base = this.branch) {
const { newVersion } = this;
const proposalBranch = `v${newVersion}-proposal`;
await runAsync('git', [
'checkout',
'-B',
proposalBranch,
base
]);
return proposalBranch;
}
async updateNodeVersion() {
const { alphaVersionComponents, isAlpha, ltsCodename, versionComponents } = this;
const filePath = path.resolve('src', 'node_version.h');
const data = await fs.readFile(filePath, 'utf8');
const arr = data.split('\n');
arr.forEach((line, idx) => {
if (line.includes('#define NODE_MAJOR_VERSION')) {
arr[idx] = `#define NODE_MAJOR_VERSION ${versionComponents.major}`;
} else if (line.includes('#define NODE_MINOR_VERSION')) {
arr[idx] = `#define NODE_MINOR_VERSION ${versionComponents.minor}`;
} else if (line.includes('#define NODE_PATCH_VERSION')) {
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) {
if (this.isLTS) {
throw new Error('Previous release was already marked as LTS.');
}
this.isLTS = true;
arr[idx] = '#define NODE_VERSION_IS_LTS 1';
arr[idx + 1] = `#define NODE_VERSION_LTS_CODENAME "${ltsCodename}"`;
} else {
this.ltsCodename = arr[idx + 1].split(' ')[2].slice(1, -1);
}
}
});
let updated = arr.join('\n');
if (!isAlpha) {
updated = updated.replace(/\n\n(?:#define NODE_ALPHA_\w+ \d+\n)+/, '\n');
}
await fs.writeFile(filePath, updated);
}
// `isAlpha` is the type of the release being prepared, and defaults to the
// type of the release line.
checkReleaseLine(major, isAlpha) {
const nodeVersionH = readFileSync(path.resolve('src', 'node_version.h'), 'utf8');
const isAlphaLine = isAlphaReleaseLine(nodeVersionH);
if (isAlpha && !isAlphaLine) {
throw new Error(`Cannot prepare Alpha release ${this.newVersion}: ` +
'src/node_version.h does not define the NODE_ALPHA_*_VERSION macros.');
}
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.');
}
this.isAlpha = isAlpha ?? isAlphaLine;
// A non-Alpha release on an Alpha release line marks it as out of Alpha.
this.isOutOfAlpha = isAlphaLine && !this.isAlpha;
this.isFirstRelease =
!existsSync(path.resolve('doc', 'changelogs', `CHANGELOG_V${major}.md`));
}
updateNodeModuleVersion(runtime, variant, versions) {
const nmvFilePath = path.resolve('doc', 'abi_version_registry.json');
const nmvArray = readJson(nmvFilePath).NODE_MODULE_VERSION;
const latestNMV = nmvArray[0];
const modules = latestNMV.modules + 1;
nmvArray.unshift({ modules, runtime, variant, versions });
writeJson(nmvFilePath, { NODE_MODULE_VERSION: nmvArray });
}
async updateTestProcessRelease(testFile) {
const data = await fs.readFile(testFile, { encoding: 'utf8' });
const updated = updateTestProcessRelease(data, this);
await fs.writeFile(testFile, updated);
}
async createReleaseCommit() {
const {
cli,
isAlpha,
isLTS,
isLTSTransition,
ltsCodename,
newVersion,
isSecurityRelease,
date
} = this;
const releaseInfo = formatReleaseInfo({ isAlpha, isLTS, ltsCodename });
const messageTitle = `${date}, Version ${newVersion} ${releaseInfo}`;
const messageBody = [];
if (isSecurityRelease) {
messageBody.push('This is a security release.\n\n');
}
const notableChanges = isSecurityRelease
? await this.getSecurityNotableChanges()
: await this.getBranchDiff({
onlyNotableChanges: true,
format: 'plaintext'
});
messageBody.push('Notable changes:\n\n');
if (isLTSTransition) {
messageBody.push(`${getStartLTSBlurb(this)}\n\n`);
}
messageBody.push(notableChanges);
messageBody.push('\nPR-URL: TODO');
// Create commit and then allow releaser to amend.
runSync('git', ['add', '.']);
runSync('git', [
'commit',
'-m',
messageTitle,
'-m',
messageBody.join('')
]);
cli.log(`${messageTitle}\n\n${messageBody.join('')}`);
const useMessage = await cli.prompt('Continue with this commit message?');
return useMessage;
}
// Build the notable changes of a security release from the commits
// cherry-picked onto the proposal branch and the severity ratings in
// vulnerabilities.json.
async getSecurityNotableChanges() {
const { upstream, versionComponents } = this;
const releaseBranch = `v${versionComponents.major}.x`;
await forceRunAsync('git', ['remote', 'set-branches', '--add', upstream, releaseBranch], {
ignoreFailures: false
});
await forceRunAsync('git', ['fetch', upstream, releaseBranch], { ignoreFailures: false });
const severityByCVE = new Map();
const vulnPath = this.getVulnerabilitiesJSONPath();
if (vulnPath && existsSync(vulnPath)) {
const { reports } = JSON.parse(readFileSync(vulnPath, 'utf-8'));
for (const report of reports ?? []) {
for (const cveId of report.cveIds ?? []) {
severityByCVE.set(cveId, report.severity?.rating);
}
}
}
const log = runSync('git', [
'log',
'--format=%s%x1f%an%x1f%(trailers:key=CVE-ID,valueonly,separator=%x2C)%x1e',
`${upstream}/${releaseBranch}..HEAD`
]);
const commits = log.split('\x1e').map(record => {
const [subject, author, cveIds] = record.trim().split('\x1f');
return {
subject,
author,
// Ignore placeholder trailers such as CVE-2026-XXXXX.
cveIds: (cveIds || '').split(',')
.map(id => id.trim())
.filter(id => /^CVE-\d{4}-\d+$/.test(id))
};
});
return formatSecurityNotableChanges(commits, severityByCVE);
}
async getBranchDiff(opts) {
const {
cli,
versionComponents = {},
upstream,
newVersion,
isLTS
} = this;
let majorVersion;
let stagingBranch;
if (Object.keys(versionComponents).length !== 0) {
majorVersion = versionComponents.major;
stagingBranch = this.stagingBranch;
} else {
stagingBranch = this.getCurrentBranch();
const stagingBranchSemver = semver.coerce(stagingBranch);
majorVersion = stagingBranchSemver.major;
}
let branchDiffOptions;
if (opts.onlyNotableChanges) {
const proposalBranch = `v${newVersion}-proposal`;
const releaseBranch = `v${this.isFirstRelease ? majorVersion - 1 : majorVersion}.x`;
const notableLabels = ['notable-change'];
if (!this.isFirstRelease) notableLabels.push('semver-minor');
if (this.isAlpha || this.isFirstRelease) notableLabels.push('semver-major');
await forceRunAsync('git', ['remote', 'set-branches', '--add', upstream, releaseBranch], {
ignoreFailures: false
});
await forceRunAsync('git', ['fetch', upstream, releaseBranch], { ignoreFailures: false });
branchDiffOptions = [
`${upstream}/${releaseBranch}`,
proposalBranch,
`--require-label=${notableLabels.join(',')}`,
`--format=${opts.format || 'markdown'}`,
'--group'
];
} else {
const excludeLabels = [
...(this.isAlpha ? [] : ['semver-major']),
`dont-land-on-v${majorVersion}.x`,
`backport-requested-v${majorVersion}.x`,
`backported-to-v${majorVersion}.x`,
`backport-blocked-v${majorVersion}.x`,
`backport-open-v${majorVersion}.x`,
'baking-for-lts'
];
let comparisonBranch = this.config.branch || 'main';
const isSemverMinor = versionComponents.patch === 0;
if (isLTS) {
const res = await fetch('https://nodejs.org/dist/index.json');
if (!res.ok) throw new Error('Failed to fetch', { cause: res });
const [latest] = await res.json();
// Assume Current branch matches tag with highest semver value.
comparisonBranch = `v${semver.coerce(latest.version).major}.x`;
if (!isSemverMinor) {
excludeLabels.push('semver-minor');
}
}
await forceRunAsync('git', ['fetch', upstream, comparisonBranch], { ignoreFailures: false });
const commits = await forceRunAsync('git', ['rev-parse', 'FETCH_HEAD', comparisonBranch], {
captureStdout: 'lines',
ignoreFailures: true
});
if (commits == null) {
const shouldCreateCompareBranch = await cli.prompt(
`No local branch ${comparisonBranch}, do you want to create it?`);
if (shouldCreateCompareBranch) {
await forceRunAsync('git', ['branch', comparisonBranch, 'FETCH_HEAD'], {
ignoreFailures: false
});
}
} else if (commits[0] !== commits[1]) {
const shouldUpBranch = cli.prompt(`Local ${comparisonBranch} branch is not in sync with ${
upstream}/${comparisonBranch}, do you want to update it?`);
if (shouldUpBranch) {
await forceRunAsync('git', ['branch', '-f', comparisonBranch, 'FETCH_HEAD'], {
ignoreFailures: false
});
}
}
branchDiffOptions = [
stagingBranch,
comparisonBranch,
`--exclude-label=${excludeLabels.join(',')}`,
'--filter-release'
];
}
const branchDiff = new URL(
'../node_modules/.bin/branch-diff' + (isWindows ? '.cmd' : ''),
import.meta.url
);
return runSync(branchDiff, branchDiffOptions);
}
async getLastRelease(major) {
const { cli } = this;
cli.startSpinner(`Parsing CHANGELOG for most recent release of v${major}.x`);
const data = await fs.readFile(
path.resolve(`doc/changelogs/CHANGELOG_V${major}.md`),
'utf8'
);
const [, version, minor, patch, alpha] =
/<a href="#(\d+\.(\d+)\.(\d+)(?:-alpha\.(\d+\.\d+\.\d+))?)">\1<\/a><br\/>/.exec(data);
this.isLTS = data.includes('<th>LTS ');
cli.stopSpinner(`Latest release on ${major}.x line is ${version}${
this.isLTS ? ' (LTS)' : ''
}`);
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() {
const { cli, isSecurityRelease } = this;
if (this.newVersion) {
// If the CLI asked for a specific version:
const newVersion = semver.parse(this.newVersion);
if (!newVersion) {
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,
minor: newVersion.minor,
patch: newVersion.patch
};
this.stagingBranch = `v${newVersion.major}.x-staging`;
this.releaseBranch = `v${newVersion.major}.x`;
await this.tryResetBranch();
this.checkReleaseLine(newVersion.major, this.isAlpha);
if (!this.isFirstRelease) {
await this.getLastRelease(newVersion.major);
}
return;
}
// Otherwise, we need to figure out what's the next version number for the
// release line of the branch that's currently checked out.
const currentBranch = this.getCurrentBranch();
// In security releases vN.x branch is the base
let regex = /^v(\d+)\.x-staging$/;
let targetBranch = 'vN.x-staging';
if (isSecurityRelease) {
regex = /^v(\d+)\.x$/;
targetBranch = 'vN.x';
}
const match = regex.exec(currentBranch);
if (!match) {
cli.warn(`Cannot prepare a release from ${currentBranch
}. Switch to a ${targetBranch} branch before proceeding.`);
return;
}
this.stagingBranch = currentBranch;
// On security releases `branch` resolves to `releaseBranch`, which
// tryResetBranch() needs. Derive it from the branch that is checked out:
// the new version is only calculated below, and it may bump the major.
this.releaseBranch = `v${match[1]}.x`;
await this.tryResetBranch();
this.checkReleaseLine(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`;
}
warnForWrongBranch() {
const {
cli,
stagingBranch,
releaseBranch,
versionComponents,
isSecurityRelease
} = this;
const rev = this.getCurrentBranch();
if (rev === 'HEAD') {
cli.warn(
'You are in detached HEAD state. Please run git-node on a valid ' +
'branch');
return true;
}
const targetBranch = isSecurityRelease ? releaseBranch : stagingBranch;
if (rev === targetBranch) {
return false;
}
cli.warn(
'You are trying to create a new release proposal branch for ' +
`v${versionComponents.major}, but you're checked out on ` +
`${rev} and not ${targetBranch}.`);
cli.separator();
cli.info(
`Switch to \`${targetBranch}\` with \`git` +
` checkout ${targetBranch}\` before proceeding.`);
cli.separator();
return true;
}
}