diff --git a/components/git/release.js b/components/git/release.js
index 47f88bba..597fe2ed 100644
--- a/components/git/release.js
+++ b/components/git/release.js
@@ -6,7 +6,7 @@ import TeamInfo from '../../lib/team_info.js';
import Request from '../../lib/request.js';
import { runPromise } from '../../lib/run.js';
-export const command = 'release [prid|options]';
+export const command = 'release [prid..]';
export const describe = 'Manage an in-progress release or start a new one.';
const PREPARE = 'prepare';
@@ -34,8 +34,13 @@ const releaseOptions = {
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 relase date when --prepare is used. It must be YYYY-MM-DD',
+ describe: 'Default release date when --prepare is used. It must be YYYY-MM-DD',
type: 'string'
},
run: {
@@ -112,11 +117,6 @@ function release(state, argv) {
}
async function main(state, argv, cli, dir) {
- const prID = /^(?:https:\/\/github\.com\/nodejs(-private)?\/node\1\/pull\/)?(\d+)$/.exec(argv.prid);
- if (prID) {
- if (prID[1]) argv.security = true;
- argv.prid = Number(prID[2]);
- }
if (state === PREPARE) {
const release = new ReleasePreparation(argv, cli, dir);
@@ -160,6 +160,24 @@ async function main(state, argv, cli, dir) {
cli.stopSpinner(`${release.username} is a Releaser`);
}
- return release.promote();
+ 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, repo, prid: Number(prid) }));
+ }
+ return release.promote(releases);
}
}
diff --git a/lib/promote_release.js b/lib/promote_release.js
index da32302c..1b9443b5 100644
--- a/lib/promote_release.js
+++ b/lib/promote_release.js
@@ -17,16 +17,10 @@ const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run
export default class ReleasePromotion extends Session {
constructor(argv, req, cli, dir) {
- super(cli, dir, argv.prid);
+ super(cli, dir);
this.req = req;
- if (argv.security) {
- this.config.owner = 'nodejs-private';
- this.config.repo = 'node-private';
- }
this.dryRun = !argv.run;
- this.isLTS = false;
- this.ltsCodename = '';
- this.date = '';
+ this.proposalUpstreamRemote = argv.fetchFrom ?? this.upstream;
this.gpgSign = argv?.['gpg-sign']
? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
: [];
@@ -43,8 +37,8 @@ export default class ReleasePromotion extends Session {
return defaultBranchRef.name;
}
- async promote() {
- const { prid, cli } = this;
+ 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.
@@ -54,9 +48,7 @@ export default class ReleasePromotion extends Session {
isApproved,
jenkinsReady,
releaseCommitSha
- } = await this.verifyPRAttributes();
-
- this.releaseCommitSha = releaseCommitSha;
+ } = await this.verifyPRAttributes({ prid, owner, repo });
let localCloneIsClean = true;
const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'],
@@ -74,7 +66,7 @@ export default class ReleasePromotion extends Session {
if (!localCloneIsClean) {
if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) {
cli.startSpinner('Fetching the proposal upstream...');
- await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha],
+ await forceRunAsync('git', ['fetch', proposalUpstreamRemote, releaseCommitSha],
{ ignoreFailure: false });
await forceRunAsync('git', ['reset', releaseCommitSha, '--hard'], { ignoreFailure: false });
cli.stopSpinner('Local HEAD is now in sync with the proposal');
@@ -84,9 +76,9 @@ export default class ReleasePromotion extends Session {
}
}
- await this.parseDataFromReleaseCommit();
+ const releaseData = await this.parseDataFromReleaseCommit(releaseCommitSha);
- const { version } = this;
+ const { version, isLTS, ltsCodename, date, versionComponents } = releaseData;
cli.startSpinner('Verifying Jenkins CI status');
if (!jenkinsReady) {
cli.stopSpinner(
@@ -134,108 +126,79 @@ export default class ReleasePromotion extends Session {
cli.warn(`Aborting release promotion for version ${version}`);
throw new Error('Aborted');
}
- await this.secureTagRelease();
- await this.verifyTagSignature();
+ await this.secureTagRelease({ version, 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();
+ const workingOnNewReleaseCommit =
+ await this.setupForNextRelease({ prid, owner, repo, versionComponents });
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.rebaseStagingBranch(workingOnNewReleaseCommit)
+ ? 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 to the default branch?', { defaultAnswer: true });
+ 'Cherry-pick release commit(s) to the default branch?', { defaultAnswer: true });
if (!shouldCherryPick) {
- cli.warn(`Aborting release promotion for version ${version}`);
throw new Error('Aborted');
}
- const appliedCleanly = await this.cherryPickToDefaultBranch();
-
- // 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 (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();
- const didResolveConflicts = await cli.prompt(
- 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
- if (!didResolveConflicts) {
- cli.warn(`Aborting release promotion for version ${version}`);
- throw new Error('Aborted');
- }
- }
+ const defaultBranch = await this.checkoutDefaultBranch();
- 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 });
+ for (const { releaseCommitSha } of releases) {
+ await this.cherryPickReleaseCommit(releaseCommitSha);
}
- // Validate release commit on the default branch
- 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'))) {
- cli.warn('Some modified files are not markdown, that\'s unusual.');
- cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
- if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
- throw new Error('Aborted');
- }
- }
-
- // Push to the remote the release tag, and default, release, and staging branch.
- await this.pushToRemote(workingOnNewReleaseCommit, tipOfStagingBranch);
+ // 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 for ${version} complete.\n`);
+ cli.ok('Release promotion(s) complete.\n');
cli.info(
'To finish this release, you\'ll need to: \n' +
- ` 1. Check the release at: https://nodejs.org/dist/v${version}\n` +
- ' 2. Create the blog post for nodejs.org.\n' +
- ' 3. Create the release on GitHub.\n' +
- ' 4. Optionally, announce the release on your social networks.\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 to create the GitHub release:');
+ cli.info('Use the following command(s) to create the GitHub release(s):');
cli.separator();
- cli.info(
- 'awk \'' +
- `/^## ${this.date}, Version ${this.version.replaceAll('.', '\\.')} /,` +
- '/^<\\x2fa>$/{' +
- 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' +
- `}' doc/changelogs/CHANGELOG_V${
- this.versionComponents.major}.md | gh release create v${this.version} --verify-tag --latest${
- this.isLTS ? '=false' : ''} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`);
+ for (const { date, version, versionComponents, isLTS, releaseTitle } of releases) {
+ cli.info(
+ 'awk \'' +
+ `/^## ${date}, Version ${version.replaceAll('.', '\\.')} /,` +
+ '/^<\\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' : ''} --title=${JSON.stringify(releaseTitle)} --notes-file -`);
+ }
}
- async verifyTagSignature() {
- const { cli, version } = this;
+ async verifyTagSignature(version) {
+ const { cli } = this;
const verifyTagPattern = /gpg:[^\n]+\ngpg:\s+using RSA key ([^\n]+)\ngpg:\s+issuer "([^"]+)"\ngpg:\s+Good signature from "([^<]+) <\2>"/;
const [verifyTagOutput, haystack] = await Promise.all([forceRunAsync(
'git', ['--no-pager',
@@ -264,8 +227,8 @@ export default class ReleasePromotion extends Session {
}
}
- async verifyPRAttributes() {
- const { cli, prid, owner, repo, req } = this;
+ async verifyPRAttributes({ prid, owner, repo }) {
+ const { cli, req } = this;
const data = new PRData({ prid, owner, repo }, cli, req);
await data.getAll();
@@ -331,8 +294,8 @@ export default class ReleasePromotion extends Session {
return data;
}
- async parseDataFromReleaseCommit() {
- const { cli, releaseCommitSha } = this;
+ async parseDataFromReleaseCommit(releaseCommitSha) {
+ const { cli } = this;
const releaseCommitMessage = await forceRunAsync('git', [
'--no-pager', 'log', '-1',
@@ -344,26 +307,19 @@ export default class ReleasePromotion extends Session {
const releaseCommitData = await this.validateReleaseCommit(releaseCommitMessage);
- this.date = releaseCommitData.date;
- this.version = releaseCommitData.version;
- this.stagingBranch = releaseCommitData.stagingBranch;
- this.versionComponents = releaseCommitData.versionComponents;
- this.isLTS = releaseCommitData.isLTS;
- this.ltsCodename = releaseCommitData.ltsCodename;
-
// Check if CHANGELOG show the correct releaser for the current release
const changeLogDiff = await forceRunAsync('git', [
'--no-pager', 'diff',
- `${this.releaseCommitSha}^..${this.releaseCommitSha}`,
+ `${releaseCommitSha}^..${releaseCommitSha}`,
'--',
- `doc/changelogs/CHANGELOG_V${this.versionComponents.major}.md`
+ `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');
}
- this.releaseTitle = headingLine[0].slice(4);
+ releaseCommitData.releaseTitle = headingLine[0].slice(4);
const expectedLine = `+## ${releaseCommitMessage}, @${this.username}`;
if (headingLine[0] !== expectedLine &&
!headingLine[0].startsWith(`${expectedLine} prepared by @`)) {
@@ -376,11 +332,11 @@ export default class ReleasePromotion extends Session {
throw new Error('Aborted');
}
}
- }
- async secureTagRelease() {
- const { version, isLTS, ltsCodename, releaseCommitSha } = this;
+ return releaseCommitData;
+ }
+ async secureTagRelease({ version, isLTS, ltsCodename, releaseCommitSha, date }) {
const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)';
try {
@@ -388,7 +344,7 @@ export default class ReleasePromotion extends Session {
const api = new gst.API(process.cwd());
api.sign(`v${version}`, releaseCommitSha, {
insecure: false,
- m: `${this.date} Node.js v${version} ${releaseInfo} Release`
+ m: `${date} Node.js v${version} ${releaseInfo} Release`
}, (err) => err ? reject(err) : resolve());
});
} catch (err) {
@@ -407,9 +363,7 @@ export default class ReleasePromotion extends Session {
// Set up the branch so that nightly builds are produced with the next
// version number and a pre-release tag.
- async setupForNextRelease() {
- const { versionComponents, prid, owner, repo } = this;
-
+ async setupForNextRelease({ prid, owner, repo, versionComponents }) {
// Update node_version.h for next patch release.
const filePath = path.resolve('src', 'node_version.h');
const nodeVersionFile = await fs.open(filePath, 'r+');
@@ -446,22 +400,16 @@ export default class ReleasePromotion extends Session {
return workingOnNewReleaseCommit.trim();
}
- async pushToRemote(workingOnNewReleaseCommit, tipOfStagingBranch) {
- const { cli, dryRun, version, versionComponents, stagingBranch } = this;
- const releaseBranch = `v${versionComponents.major}.x`;
- const tagVersion = `v${version}`;
+ async pushToRemote(upstream, ...refs) {
+ const { cli, dryRun } = this;
this.defaultBranch ??= await this.getDefaultBranch();
- let prompt = `Push release tag and commits to ${this.upstream}?`;
+ 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 ${this.upstream} ${
- this.defaultBranch} ${
- tagVersion} ${
- workingOnNewReleaseCommit}:refs/heads/${releaseBranch} +${
- tipOfStagingBranch}:refs/heads/${stagingBranch}`);
+ cli.info(`git push ${upstream} ${refs.join(' ')}`);
cli.warn('Once pushed, you must not delete the local tag');
prompt = 'Ready to continue?';
}
@@ -475,12 +423,8 @@ export default class ReleasePromotion extends Session {
}
cli.startSpinner('Pushing to remote');
- await forceRunAsync('git', ['push', this.upstream, this.defaultBranch, tagVersion,
- `${workingOnNewReleaseCommit}:refs/heads/${releaseBranch}`,
- `+${tipOfStagingBranch}:refs/heads/${stagingBranch}`],
- { ignoreFailure: false });
- cli.stopSpinner(`Pushed ${tagVersion}, ${this.defaultBranch}, ${
- releaseBranch}, and ${stagingBranch} 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');
}
@@ -513,10 +457,10 @@ export default class ReleasePromotion extends Session {
cli.stopSpinner('Release has been signed and promoted');
}
- async rebaseStagingBranch(workingOnNewReleaseCommit) {
- const { cli, stagingBranch, upstream } = this;
+ async rebaseRemoteBranch(branchName, workingOnNewReleaseCommit) {
+ const { cli, upstream } = this;
cli.startSpinner('Fetch staging branch');
- await forceRunAsync('git', ['fetch', upstream, stagingBranch], { ignoreFailure: false });
+ 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',
@@ -528,21 +472,76 @@ export default class ReleasePromotion extends Session {
return tipOfStagingBranch.trim();
}
- async cherryPickToDefaultBranch() {
+ async checkoutDefaultBranch() {
this.defaultBranch ??= await this.getDefaultBranch();
- const releaseCommitSha = this.releaseCommitSha;
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, releaseCommitSha],
+ await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, commit],
{ ignoreFailure: false });
return true;
} catch {
return false;
}
}
+
+ async cherryPickReleaseCommit(releaseCommitSha) {
+ const { cli } = this;
+
+ 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 (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();
+ const didResolveConflicts = await cli.prompt(
+ 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
+ if (!didResolveConflicts) {
+ throw new Error('Aborted');
+ }
+ }
+
+ 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
+ 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'))) {
+ cli.warn('Some modified files are not markdown, that\'s unusual.');
+ cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
+ if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
+ throw new Error('Aborted');
+ }
+ }
+ }
}