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;
}
}
}
}