current commit
261028.2 · feat(release): support alpha releases (#1224)
import auth from '../../lib/auth.js';
import CLI from '../../lib/cli.js';
import ReleasePreparation from '../../lib/prepare_release.js';
import ReleasePromotion from '../../lib/promote_release.js';
import TeamInfo from '../../lib/team_info.js';
import Request from '../../lib/request.js';
import { forceRunAsync, runPromise } from '../../lib/run.js';

export const command = 'release [prid..]';
export const describe = 'Manage an in-progress release or start a new one.';

const PREPARE = 'prepare';
const PROMOTE = 'promote';
const RELEASERS = 'releasers';

const releaseOptions = {
  'gpg-sign': {
    describe: 'GPG-sign commits, will be passed to the git process',
    alias: 'S'
  },
  newVersion: {
    describe: 'Version number of the release to be prepared',
    type: 'string'
  },
  prepare: {
    describe: 'Prepare a new release of Node.js',
    type: 'boolean'
  },
  promote: {
    describe: 'Promote new release of Node.js',
    type: 'boolean'
  },
  fetchFrom: {
    describe: 'Remote to fetch the release proposal(s) from, if different from the one where to' +
              'push the tags and commits.',
    type: 'string',
  },
  releaseDate: {
    describe: 'Default release date when --prepare is used. It must be YYYY-MM-DD',
    type: 'string'
  },
  run: {
    describe: 'Run steps that involve touching more than the local clone, ' +
           'including `git push` commands. Might not work if a passphrase ' +
           'required to push to the remote clone.',
    type: 'boolean'
  },
  security: {
    describe: 'Demarcate the new security release as a security release. ' +
              'Optionally provide the path to vulnerabilities.json (or the ' +
              'security-release repository) to cherry-pick the reports and ' +
              'dependency updates for this release line and auto-populate CVEs',
    type: 'string',
    coerce: (arg) => {
      // If --security=path is used, return the path
      if (arg === '' || arg === true) return true;
      return arg;
    }
  },
  skipBranchDiff: {
    describe: 'Skips the initial branch-diff check when preparing releases',
    type: 'boolean'
  },
  startLTS: {
    describe: 'Mark the release as the transition from Current to LTS',
    type: 'boolean'
  },
  yes: {
    type: 'boolean',
    default: false,
    describe: 'Skip all prompts and run non-interactively'
  }
};

let yargsInstance;

export function builder(yargs) {
  yargsInstance = yargs;
  return yargs
    .options(releaseOptions).positional('prid', {
      describe: 'PR number or URL of the release proposal to be promoted',
      type: 'string'
    })
    .example('git node release --prepare --security',
      'Prepare a new security release of Node.js with auto-determined version')
    .example('git node release --prepare --newVersion=1.2.3',
      'Prepare a new release of Node.js tagged v1.2.3')
    .example('git node release --prepare --newVersion=27.0.0-alpha.0.0.0',
      'Prepare the first Alpha release of Node.js 27')
    .example('git node release --promote https://github.com/nodejs/node/pull/12345 https://github.com/nodejs/node/pull/54321',
      'Promote two prepared releases of Node.js with PR #12345 and #54321')
    .example('git node --prepare --startLTS',
      'Prepare the first LTS release');
}

export function handler(argv) {
  if (argv.prepare) {
    return release(PREPARE, argv);
  } else if (argv.promote) {
    return release(PROMOTE, argv);
  }

  // If more than one action is provided or no valid action
  // is provided, show help.
  yargsInstance.showHelp();
}

function release(state, argv) {
  const logStream = process.stdout.isTTY ? process.stdout : process.stderr;
  const cli = new CLI(logStream);
  const dir = process.cwd();

  if (argv.yes) {
    cli.setAssumeYes();
  }

  return runPromise(wrapStash(() => main(state, argv, cli, dir))).catch((err) => {
    if (cli.spinner.enabled) {
      cli.spinner.fail();
    }
    throw err;
  });
}

async function wrapStash(fn) {
  const stashTip = await forceRunAsync('git', ['stash', 'list', '-1', '--format="%gd"'],
    { ignoreFailure: false, captureStdout: true });
  await forceRunAsync('git', ['stash', '--include-untracked'], { ignoreFailure: false });
  const newStashTip = await forceRunAsync('git', ['stash', 'list', '-1', '--format="%gd"'],
    { ignoreFailure: false, captureStdout: true });
  const hasStashed = newStashTip !== stashTip && newStashTip.trim();
  try {
    await fn();
  } finally {
    if (hasStashed) {
      await forceRunAsync('git', ['stash', 'pop'], { ignoreFailure: false });
    }
  }
}

async function main(state, argv, cli, dir) {
  if (state === PREPARE) {
    const release = new ReleasePreparation(argv, cli, dir);

    await release.prepareLocalBranch();

    if (release.warnForWrongBranch()) return;

    // If the new version was automatically calculated, confirm it.
    if (!argv.newVersion) {
      const create = await cli.prompt(
        `Create release with new version ${release.newVersion}?`,
        { defaultAnswer: true });

      if (!create) {
        cli.error('Aborting release preparation process');
        return;
      }
    }

    return release.prepare();
  } else if (state === PROMOTE) {
    const credentials = await auth({ github: true });
    const request = new Request(credentials);
    const release = new ReleasePromotion(argv, request, cli, dir);

    cli.startSpinner('Verifying Releaser status');
    const info = new TeamInfo(cli, request, 'nodejs', RELEASERS);

    const releasers = await info.getMembers();
    if (release.username === undefined) {
      cli.stopSpinner('Failed to verify Releaser status');
      cli.info(
        'Username was undefined - do you have your .ncurc set up correctly?');
      return;
    } else if (releasers.every(r => r.login !== release.username)) {
      cli.stopSpinner(`${release.username} is not a Releaser`, 'failed');
      if (!argv.dryRun) {
        throw new Error('aborted');
      }
    } else {
      cli.stopSpinner(`${release.username} is a Releaser`);
    }

    const releases = [];
    for (const pr of argv.prid) {
      const match = /^(?:https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/)?(\d+)(?:#.*)?$/.exec(pr);
      if (!match) throw new Error('Invalid PR ID or URL', { cause: pr });
      const [,owner, repo, prid] = match;

      if (
        owner &&
        (owner !== release.owner || repo !== release.repo) &&
        !argv.fetchFrom
      ) {
        console.warn('The configured owner/repo does not match the PR URL.');
        console.info('You should either pass `--fetch-from` flag or check your configuration');
        console.info(`E.g. --fetch-from=git@github.com:${owner}/${repo}.git`);
        throw new Error('You need to tell what remote use to fetch security release proposal.');
      }
      releases.push(await release.preparePromotion({
        owner: owner ?? release.owner,
        repo: repo ?? release.repo,
        prid: Number(prid)
      }));
    }
    return release.promote(releases);
  }
}