@ 261105.4
current
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;
  }
}