@ 261105.4
current
import path from 'node:path';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { pipeline } from 'node:stream/promises';
import semver from 'semver';
import * as gst from 'git-secure-tag';

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 ' +
                      'the `git push` commands, you would need to copy-paste the ' +
                      'following command in another terminal window. Alternatively, ' +
                      'pass `--run` flag to ask NCU to run the command for you ' +
                      '(might not work if you need to type a passphrase to push to the remote).';

export default class ReleasePromotion extends Session {
  constructor(argv, req, cli, dir) {
    super(cli, dir, null, argv);
    this.req = req;
    this.dryRun = !argv.run;
    this.proposalUpstreamRemote = argv.fetchFrom ?? this.upstream;
  }

  get branch() {
    return this.defaultBranch ?? this.config.branch;
  }

  async getDefaultBranch() {
    const { repository: { defaultBranchRef } } = await this.req.gql(
      'DefaultBranchRef',
      { owner: this.owner, repo: this.repo });
    return defaultBranchRef.name;
  }

  async preparePromotion({ prid, owner, repo }) {
    const { cli, proposalUpstreamRemote } = this;

    // In the promotion stage, we can pull most relevant data
    // from the release commit created in the preparation stage.
    // Verify that PR is ready to promote.
    const {
      githubCIReady,
      isApproved,
      jenkinsReady,
      releaseCommitSha
    } = await this.verifyPRAttributes({ prid, owner, repo });

    let localCloneIsClean = true;
    const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'],
      { captureStdout: true, ignoreFailure: false });
    if (currentHEAD.trim() !== releaseCommitSha) {
      cli.warn('Current HEAD is not the release commit');
      localCloneIsClean = false;
    }
    if (!localCloneIsClean) {
      if (await cli.prompt('Should we checkout the release proposal?')) {
        cli.startSpinner('Fetching the proposal upstream...');
        // We need a sync spawn here to avoid the spinner getting in the way of SSH key passphrase
        runSync('git', ['fetch', proposalUpstreamRemote, releaseCommitSha],
          { ignoreFailure: false });
        await forceRunAsync('git', ['checkout', releaseCommitSha], { ignoreFailure: false });
        cli.stopSpinner('Local HEAD is now in sync with the proposal');
      } else {
        cli.error('Local clone is not ready');
        throw new Error('Aborted');
      }
    }

    const releaseData = await this.parseDataFromReleaseCommit(releaseCommitSha);

    const {
      version,
      isAlpha,
      isLTS,
      ltsCodename,
      date,
      versionComponents,
      alphaVersionComponents
    } = releaseData;
    cli.startSpinner('Verifying Jenkins CI status');
    if (!jenkinsReady) {
      cli.stopSpinner(
        `Jenkins CI is failing for #${prid}`, cli.SPINNER_STATUS.FAILED);
      const proceed = await cli.prompt('Do you want to proceed?');
      if (!proceed) {
        cli.warn(`Aborting release promotion for version ${version}`);
        throw new Error('Aborted');
      }
    } else {
      cli.stopSpinner('Jenkins CI is passing');
    }

    cli.startSpinner('Verifying GitHub CI status');
    if (!githubCIReady) {
      cli.stopSpinner(
        `GitHub CI is failing for #${prid}`, cli.SPINNER_STATUS.FAILED);
      const proceed = await cli.prompt('Do you want to proceed?');
      if (!proceed) {
        cli.warn(`Aborting release promotion for version ${version}`);
        throw new Error('Aborted');
      }
    } else {
      cli.stopSpinner('GitHub CI is passing');
    }

    cli.startSpinner('Verifying PR approval status');
    if (!isApproved) {
      cli.stopSpinner(
        `#${prid} does not have sufficient approvals`,
        cli.SPINNER_STATUS.FAILED);
      const proceed = await cli.prompt('Do you want to proceed?');
      if (!proceed) {
        cli.warn(`Aborting release promotion for version ${version}`);
        throw new Error('Aborted');
      }
    } else {
      cli.stopSpinner(`#${prid} has necessary approvals`);
    }

    // Create and sign the release tag.
    const shouldTagAndSignRelease = await cli.prompt(
      'Tag and sign the release?');
    if (!shouldTagAndSignRelease) {
      cli.warn(`Aborting release promotion for version ${version}`);
      throw new Error('Aborted');
    }
    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, alphaVersionComponents
      });
    cli.stopSpinner('Successfully set up for next release');

    const shouldRebaseStagingBranch = await cli.prompt(
      'Rebase staging branch on top of the release commit?', { defaultAnswer: true });
    const tipOfStagingBranch = shouldRebaseStagingBranch
      ? await this.rebaseRemoteBranch(
          `v${versionComponents.major}.x-staging`,
          workingOnNewReleaseCommit)
      : workingOnNewReleaseCommit;

    return { releaseCommitSha, workingOnNewReleaseCommit, tipOfStagingBranch, ...releaseData };
  }

  async promote(releases) {
    const { cli } = this;

    // Cherry pick release commit to master.
    const shouldCherryPick = await cli.prompt(
      'Cherry-pick release commit(s) to the default branch?', { defaultAnswer: true });
    if (!shouldCherryPick) {
      throw new Error('Aborted');
    }
    const defaultBranch = await this.checkoutDefaultBranch();

    for (const { releaseCommitSha, version, versionComponents } of releases) {
      await this.cherryPickReleaseCommit(releaseCommitSha, { version, versionComponents });
    }

    // Push to the remote the release tag(s), and default, release, and staging branches.
    await this.pushToRemote(this.upstream, defaultBranch, ...releases.flatMap(
      ({ version, versionComponents, workingOnNewReleaseCommit, tipOfStagingBranch }) => [
        `v${version}`,
        `${workingOnNewReleaseCommit}:refs/heads/v${versionComponents.major}.x`,
        `+${tipOfStagingBranch}:refs/heads/v${versionComponents.major}.x-staging`,
      ]));

    // Promote and sign the release builds.
    await this.promoteAndSignRelease();

    cli.separator();
    cli.ok('Release promotion(s) complete.\n');
    cli.info(
      'To finish this release, you\'ll need to: \n' +
      ' 1. Check the release(s) at: https://nodejs.org/dist/v{version}\n' +
      ' 2. Create the blog post(s) for nodejs.org.\n' +
      ' 3. Create the release(s) on GitHub.\n' +
      ' 4. Optionally, announce the release(s) on your social networks.\n' +
      ' 5. Tag @nodejs-social-team on #nodejs-release Slack channel.\n');

    cli.separator();
    cli.info('Use the following command(s) to create the GitHub release(s):');
    cli.separator();
    for (const { date, version, versionComponents, isAlpha, isLTS, releaseTitle } of releases) {
      cli.info(
        'awk \'' +
        `/^## ${date}, Version ${version.replaceAll('.', '\\.')} /,` +
        '/^<a id="[0-9]+\\.[0-9]+\\.[0-9]+(-alpha\\.[0-9]+\\.[0-9]+\\.[0-9]+)?"><\\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' : ''}${isAlpha ? ' --prerelease' : ''} --title=${
              JSON.stringify(releaseTitle)} --notes-file -`);
    }
  }

  async verifyTagSignature(version) {
    const { cli } = this;

    cli.startSpinner('Downloading active releasers keyring from nodejs/release-keys...');
    const [keyRingStream, [GNUPGHOME, keyRingFd]] = await Promise.all([
      fetch('https://github.com/nodejs/release-keys/raw/HEAD/gpg-only-active-keys/pubring.kbx'),
      fs.mkdtemp(path.join(tmpdir(), 'ncu-'))
        .then(async d => [d, await fs.open(path.join(d, 'pubring.kbx'), 'w')]),
    ]);
    if (!keyRingStream.ok) throw new Error('Failed to download keyring', { cause: keyRingStream });
    await pipeline(keyRingStream.body, keyRingFd.createWriteStream());
    cli.stopSpinner('Active releasers keyring stored in temp directory');

    try {
      await forceRunAsync(
        'git', ['--no-pager',
          'verify-tag',
          `v${version}`
        ], {
          ignoreFailure: false,
          spawnArgs: { env: { ...process.env, GNUPGHOME } },
        });
      cli.ok('git tag signature verified');
    } catch (cause) {
      cli.error('git was not able to verify the tag');
      cli.warn('This means that either the tag was signed with the wrong key,');
      cli.warn('or that nodejs/release-keys contains outdated information.');
      cli.warn('The release should not proceed.');
      if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
        if (await cli.prompt('Do you want to delete the local tag?')) {
          await forceRunAsync('git', ['tag', '-d', `v${version}`]);
        } else {
          cli.info(`Run 'git tag -d v${version}' to remove the local tag.`);
        }
        throw new Error('Aborted', { cause });
      }
    } finally {
      cli.startSpinner('Cleaning up temp files');
      await fs.rm(GNUPGHOME, { force: true, recursive: true });
      cli.stopSpinner('Temp files removed');
    }
  }

  async verifyPRAttributes({ prid, owner, repo }) {
    const { cli, req } = this;

    const data = new PRData({ prid, owner, repo }, cli, req);
    await data.getAll();

    const checker = new PRChecker(cli, data, { prid, owner, repo }, { maxCommits: 0 });
    const jenkinsReady = checker.checkJenkinsCI();
    const githubCIReady = checker.checkGitHubCI();
    const isApproved = checker.checkReviewsAndWait(new Date(), false, true);

    return {
      githubCIReady,
      isApproved,
      jenkinsReady,
      releaseCommitSha: data.commits.at(-1).commit.oid
    };
  }

  async validateReleaseCommit(releaseCommitMessage) {
    const { cli } = this;
    const data = {};
    // Parse out release date.
    if (!/^\d{4}-\d{2}-\d{2}, Version \d/.test(releaseCommitMessage)) {
      cli.error(`Invalid Release commit message: ${releaseCommitMessage}`);
      throw new Error('Aborted');
    }
    data.date = releaseCommitMessage.slice(0, 10);
    const systemDate = new Date().toISOString().slice(0, 10);
    if (data.date !== systemDate) {
      cli.warn(
        `The release date (${data.date}) does not match the system date for today (${systemDate}).`
      );
      if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
        throw new Error('Aborted');
      }
    }

    // Parse out release version.
    data.version = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20));
    const version = semver.parse(data.version);
    if (!version) {
      cli.error(`Release commit contains invalid semantic version: ${data.version}`);
      throw new Error('Aborted');
    }

    const { major, minor, patch } = version;
    data.stagingBranch = `v${major}.x-staging`;
    data.versionComponents = {
      major,
      minor,
      patch
    };

    // Parse out LTS status and codename.
    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 Alpha, Current or LTS release format');
        throw new Error('Aborted');
      }
      data.isLTS = true;
      data.ltsCodename = match[1];
    }
    return data;
  }

  async parseDataFromReleaseCommit(releaseCommitSha) {
    const { cli } = this;

    const releaseCommitMessage = await forceRunAsync('git', [
      '--no-pager', 'log', '-1',
      releaseCommitSha,
      '--pretty=format:%s'], {
      captureStdout: true,
      ignoreFailure: false
    });

    const releaseCommitData = await this.validateReleaseCommit(releaseCommitMessage);

    // Check if CHANGELOG show the correct releaser for the current release
    const changeLogDiff = await forceRunAsync('git', [
      '--no-pager', 'diff',
      `${releaseCommitSha}^..${releaseCommitSha}`,
      '--',
      `doc/changelogs/CHANGELOG_V${releaseCommitData.versionComponents.major}.md`
    ], { captureStdout: true, ignoreFailure: false });
    const headingLine = /^\+## \d{4}-\d{2}-\d{2}, Version \d.+$/m.exec(changeLogDiff);
    if (headingLine == null) {
      cli.error('Cannot find section for the new release in CHANGELOG');
      throw new Error('Aborted');
    }
    releaseCommitData.releaseTitle = headingLine[0].slice(4);
    const expectedLine = `+## ${releaseCommitMessage}, @${this.username}`;
    if (headingLine[0] !== expectedLine &&
        !headingLine[0].startsWith(`${expectedLine} prepared by @`)) {
      cli.error(
        `Invalid section heading for CHANGELOG. Expected "${
          expectedLine.slice(1)
        }", found "${headingLine[0].slice(1)}`
      );
      if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
        throw new Error('Aborted');
      }
    }

    return releaseCommitData;
  }

  async secureTagRelease({ version, isAlpha, isLTS, ltsCodename, releaseCommitSha, date }) {
    let releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)';
    if (isAlpha) releaseInfo = '(Alpha)';

    try {
      await new Promise((resolve, reject) => {
        const api = new gst.API(process.cwd());
        api.sign(`v${version}`, releaseCommitSha, {
          insecure: false,
          m: `${date} Node.js v${version} ${releaseInfo} Release`
        }, (err) => err ? reject(err) : resolve());
      });
    } catch (err) {
      const tagCommitSHA = await forceRunAsync('git', [
        'rev-parse', `refs/tags/v${version}^0`
      ], { captureStdout: true, ignoreFailure: false });
      if (tagCommitSHA.trim() !== releaseCommitSha) {
        throw new Error(
          `Existing version tag points to ${tagCommitSHA.trim()} instead of ${releaseCommitSha}`,
          { cause: err }
        );
      }
      this.cli.info('Using the existing tag');
    }
  }

  // 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, alphaVersionComponents }) {
    // Update node_version.h for next patch release.
    const filePath = path.resolve('src', 'node_version.h');
    const data = await fs.readFile(filePath, 'utf8');
    await fs.writeFile(filePath, updateNodeVersionForNextRelease(data, {
      versionComponents,
      alphaVersionComponents
    }));

    const workingOnVersion =
      `v${incrementVersion(formatVersion(versionComponents, alphaVersionComponents), 'patch')}`;

    // Create 'Working On' commit.
    await forceRunAsync('git', ['add', filePath], { ignoreFailure: false });
    await forceRunAsync('git', [
      'commit',
      ...this.gpgSign,
      '-m',
      `Working on ${workingOnVersion}`,
      '-m',
      `PR-URL: https://github.com/${owner}/${repo}/pull/${prid}`
    ], { ignoreFailure: false });
    const workingOnNewReleaseCommit = await forceRunAsync('git', ['rev-parse', 'HEAD'],
      { ignoreFailure: false, captureStdout: true });
    return workingOnNewReleaseCommit.trim();
  }

  async pushToRemote(upstream, ...refs) {
    const { cli, dryRun } = this;

    this.defaultBranch ??= await this.getDefaultBranch();

    let prompt = `Push release tag and commits to ${upstream}?`;
    if (dryRun) {
      cli.info(dryRunMessage);
      cli.info('Run the following command to push to remote:');
      cli.info(`git push ${upstream} ${refs.join(' ')}`);
      cli.warn('Once pushed, you must not delete the local tag');
      prompt = 'Ready to continue?';
    }

    const shouldPushTag = await cli.prompt(prompt, { defaultAnswer: true });
    if (!shouldPushTag) {
      cli.warn('Aborting release promotion');
      throw new Error('Aborted');
    } else if (dryRun) {
      return;
    }

    cli.startSpinner('Pushing to remote');
    await forceRunAsync('git', ['push', upstream, ...refs], { ignoreFailure: false });
    cli.stopSpinner(`Pushed ${JSON.stringify(refs)} to remote`);
    cli.warn('Now that it has been pushed, you must not delete the local tag');
  }

  async promoteAndSignRelease() {
    const { cli, dryRun } = this;
    let prompt = 'Promote and sign release builds?';

    if (dryRun) {
      cli.info(dryRunMessage);
      cli.info('Run the following command to sign and promote the release:');
      cli.info('./tools/release.sh -i <keyPath>');
      prompt = 'Ready to continue?';
    }
    const shouldPromote = await cli.prompt(prompt, { defaultAnswer: true });
    if (!shouldPromote) {
      cli.warn('Aborting release promotion');
      throw new Error('Aborted');
    } else if (dryRun) {
      return;
    }

    // TODO: move this to .ncurc
    const defaultKeyPath = '~/.ssh/node_id_rsa';
    const keyPath = await cli.prompt(
      `Please enter the path to your ssh key (Default ${defaultKeyPath}): `,
      { questionType: 'input', defaultAnswer: defaultKeyPath });

    cli.startSpinner('Signing and promoting the release');
    await forceRunAsync('./tools/release.sh', ['-i', keyPath], { ignoreFailure: false });
    cli.stopSpinner('Release has been signed and promoted');
  }

  async rebaseRemoteBranch(branchName, workingOnNewReleaseCommit) {
    const { cli, upstream } = this;
    cli.startSpinner('Fetch staging branch');
    await forceRunAsync('git', ['fetch', upstream, branchName], { ignoreFailure: false });
    cli.updateSpinner('Reset and rebase');
    await forceRunAsync('git', ['reset', 'FETCH_HEAD', '--hard'], { ignoreFailure: false });
    await forceRunAsync('git',
      ['rebase', workingOnNewReleaseCommit, ...this.gpgSign], { ignoreFailure: false });
    const tipOfStagingBranch = await forceRunAsync('git', ['rev-parse', 'HEAD'],
      { ignoreFailure: false, captureStdout: true });
    cli.stopSpinner('Rebased successfully');

    return tipOfStagingBranch.trim();
  }

  async checkoutDefaultBranch() {
    this.defaultBranch ??= await this.getDefaultBranch();
    await forceRunAsync('git', ['checkout', this.defaultBranch], { ignoreFailure: false });

    await this.tryResetBranch();

    return this.defaultBranch;
  }

  async cherryPick(commit) {
    // There might be conflicts, we do not want to treat this as a hard failure,
    // but we want to retain that information.
    try {
      await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, commit],
        { ignoreFailure: false });
      return true;
    } catch {
      return false;
    }
  }

  async cherryPickReleaseCommit(releaseCommitSha, { version, versionComponents } = {}) {
    const { cli } = this;
    // The first Alpha release follows the branch-off, after which the default
    // branch moves to the next major.
    const isBranchOff = version === `${versionComponents?.major}.0.0-alpha.0.0.0`;

    const appliedCleanly = await this.cherryPick(releaseCommitSha);
    // Ensure `node_version.h`'s `NODE_VERSION_IS_RELEASE` bit is not updated
    await forceRunAsync('git', ['checkout',
      appliedCleanly
        ? 'HEAD^' // In the absence of conflict, the top of the remote branch is the commit before.
        : 'HEAD', // In case of conflict, HEAD is still the top of the remove branch.
      '--', 'src/node_version.h'],
    { ignoreFailure: false });
    if (isBranchOff) {
      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
      // `node_version.h` changes.
      await forceRunAsync('git', ['commit', ...this.gpgSign, '--amend', '--no-edit', '-n'],
        { ignoreFailure: false });
    } else {
      // There will be remaining cherry-pick conflicts the Releaser will
      // need to resolve, so confirm they've been resolved before
      // proceeding with next steps.
      cli.separator();
      cli.info('Resolve the conflicts and commit the result');
      cli.separator();
      await cli.prompt('Ready to continue?', { defaultAnswer: true });
    }

    if (existsSync('.git/CHERRY_PICK_HEAD')) {
      cli.info('Cherry-pick is still in progress, attempting to continue it.');
      await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, '--continue'],
        { ignoreFailure: false });
    }

    // Validate release commit on the default branch
    while (true) {
      const releaseCommitOnDefaultBranch =
        await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
          { captureStdout: true, ignoreFailure: false });
      const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
      await this.validateReleaseCommit(commitTitle);
      if (!modifiedFiles.some(file => !file.endsWith('.md') &&
          !(isBranchOff && file === 'src/node_version.h'))) break;

      cli.warn(
        'Some modified files are not markdown, that\'s unusual. Consider amending the commit.'
      );
      cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
      if (await cli.prompt('Ignore and continue anyway?', {
        defaultAnswer: false
      })) {
        break;
      }
    }
  }
}