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'); + } + } + } }