From 9ee6605d59e8d595e052ce6ee08f51be8ddef883 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 8 Apr 2020 22:52:49 -0700 Subject: [PATCH 01/47] git-node: add release promotion step --- components/git/release.js | 42 +++++- lib/promote_release.js | 300 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 lib/promote_release.js diff --git a/components/git/release.js b/components/git/release.js index d711d11a..f88a90a3 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -2,12 +2,17 @@ const yargs = require('yargs'); +const auth = require('../../lib/auth'); const CLI = require('../../lib/cli'); const ReleasePreparation = require('../../lib/prepare_release'); +const ReleasePromotion = require('../../lib/promote_release'); +const TeamInfo = require('../../lib/team_info'); +const Request = require('../../lib/request'); const { runPromise } = require('../../lib/run'); const PREPARE = 'prepare'; const PROMOTE = 'promote'; +const RELEASERS = 'releasers'; const releaseOptions = { prepare: { @@ -27,10 +32,14 @@ const releaseOptions = { function builder(yargs) { return yargs .options(releaseOptions).positional('newVersion', { - describe: 'Version number of the release to be prepared or promoted' + describe: 'Version number of the release to be prepared' + }).positional('prid', { + describe: 'PR number of the release to be promoted' }) .example('git node release --prepare 1.2.3', - 'Prepare a new release of Node.js tagged v1.2.3'); + 'Prepare a new release of Node.js tagged v1.2.3') + .example('git node release --promote 12345', + 'Promote a prepared release of Node.js with PR #12345'); } function handler(argv) { @@ -59,7 +68,7 @@ function release(state, argv) { } module.exports = { - command: 'release [newVersion|options]', + command: 'release [newVersion|prid|options]', describe: 'Manage an in-progress release or start a new one.', builder, @@ -67,15 +76,17 @@ module.exports = { }; async function main(state, argv, cli, dir) { + let release; + if (state === PREPARE) { - const prep = new ReleasePreparation(argv, cli, dir); + release = new ReleasePreparation(argv, cli, dir); - if (prep.warnForWrongBranch()) return; + 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 ${prep.newVersion}?`, + `Create release with new version ${release.newVersion}?`, { defaultAnswer: true }); if (!create) { @@ -84,8 +95,23 @@ async function main(state, argv, cli, dir) { } } - return prep.prepare(); + return release.prepare(); } else if (state === PROMOTE) { - // TODO(codebytere): implement release promotion. + release = new ReleasePromotion(argv, cli, dir); + + cli.startSpinner('Verifying Releaser status'); + const credentials = await auth({ github: true }); + const request = new Request(credentials); + const info = new TeamInfo(cli, request, 'nodejs', RELEASERS); + + const releasers = await info.getMembers(); + if (!releasers.some(r => r.login === release.username)) { + cli.stopSpinner( + `${release.username} is not a Releaser; aborting release`); + return; + } + cli.stopSpinner('Verified Releaser status'); + + return release.promote(); } } diff --git a/lib/promote_release.js b/lib/promote_release.js new file mode 100644 index 00000000..ed46ee45 --- /dev/null +++ b/lib/promote_release.js @@ -0,0 +1,300 @@ +'use strict'; + +const path = require('path'); +const { promises: fs } = require('fs'); +const semver = require('semver'); + +const { getMergedConfig } = require('./config'); +const { runSync } = require('./run'); +const auth = require('./auth'); +const PRData = require('./pr_data'); +const PRChecker = require('./pr_checker'); +const Request = require('./request'); + +const isWindows = process.platform === 'win32'; + +class ReleasePromotion { + constructor(argv, cli, dir) { + this.cli = cli; + this.dir = dir; + this.isLTS = false; + this.prid = argv.prid; + this.ltsCodename = ''; + this.date = ''; + this.config = getMergedConfig(this.dir); + } + + async promote() { + const { version, prid, cli } = this; + + // In the promotion stage, we can pull most relevant data + // from the release commit created in the preparation stage. + await this.parseDataFromReleaseCommit(); + + // Verify that PR is ready to promote. + cli.startSpinner('Verifying PR promotion readiness'); + const { + jenkinsReady, + githubCIReady, + isApproved + } = await this.verifyPRAttributes(); + if (!jenkinsReady) { + cli.stopSpinner(`Jenkins CI is failing for #${prid}`); + const proceed = await cli.prompt('Do you want to proceed?'); + if (!proceed) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + } else if (!githubCIReady) { + cli.stopSpinner(`GitHub CI is failing for #${prid}`); + const proceed = await cli.prompt('Do you want to proceed?'); + if (!proceed) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + } else if (!isApproved) { + cli.stopSpinner(`#${prid} does not have sufficient approvals`); + const proceed = await cli.prompt('Do you want to proceed?'); + if (!proceed) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + } + cli.stopSpinner(`The release PR for ${version} is ready to promote!`); + + // 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}`); + return; + } + this.secureTagRelease(); + + // Set up for next release. + cli.startSpinner('Setting up for next release'); + await this.setupForNextRelease(); + cli.startSpinner('Successfully set up for next release'); + + const shouldMergeProposalBranch = await cli.prompt( + 'Merge proposal branch into staging branch?'); + if (!shouldMergeProposalBranch) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + + // Merge vX.Y.Z-proposal into vX.x. + cli.startSpinner('Merging proposal branch'); + await this.mergeProposalBranch(); + cli.startSpinner('Merged proposal branch'); + + // Cherry pick release commit to master. + const shouldCherryPick = await cli.prompt( + 'Cherry-pick release commit to master?', { defaultAnswer: true }); + if (!shouldCherryPick) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + await this.cherryPickToMaster(); + + // Push release tag. + const shouldPushTag = await cli.prompt('Push release tag?', + { defaultAnswer: true }); + if (!shouldPushTag) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + this.pushReleaseTag(); + + // Promote and sign the release builds. + const shouldPromote = await cli.prompt('Promote and sign release builds?', + { defaultAnswer: true }); + if (!shouldPromote) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } + + 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 }); + this.promoteAndSignRelease(keyPath); + + cli.separator(); + cli.ok(`Release promotion for ${version} 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' + + 'Finally, proceed to Twitter and announce the new release!'); + } + + async verifyPRAttributes() { + const { cli, prid, owner, repo } = this; + + const credentials = await auth({ github: true }); + const request = new Request(credentials); + + const data = new PRData({ prid, owner, repo }, cli, request); + await data.getAll(); + + const checker = new PRChecker(cli, data, { prid, owner, repo }); + const jenkinsReady = checker.checkJenkinsCI(); + const githubCIReady = checker.checkGitHubCI(); + const isApproved = checker.checkReviewsAndWait(false /* checkComments */); + + return { + jenkinsReady, + githubCIReady, + isApproved + }; + } + + async parseDataFromReleaseCommit() { + const { cli } = this; + + const releaseCommitMessage = runSync( + 'git', ['log', '-n', '1', '--pretty=format:\'%s\'']).trim(); + + const components = releaseCommitMessage.split(' '); + + // Parse out release date. + if (!/\d{4}-\d{2}-\d{2}/.match(components[0])) { + cli.error(`Release commit contains invalid date: ${components[0]}`); + return; + } + this.date = components[0]; + + // Parse out release version. + const version = semver.clean(components[2]); + if (!semver.valid(version)) { + cli.error(`Release commit contains invalid semantic version: ${version}`); + return; + } + + this.version = version; + this.stagingBranch = `v${semver.major(version)}.x-staging`; + this.versionComponents = { + major: semver.major(version), + minor: semver.minor(version), + patch: semver.patch(version) + }; + + // Parse out LTS status and codename. + if (components.length === 5) { + this.isLTS = true; + this.ltsCodename = components[3]; + } + } + + getCommitSha(position = 0) { + return runSync('git', ['rev-parse', `HEAD~${position}`]); + } + + get owner() { + return this.config.owner || 'nodejs'; + } + + get repo() { + return this.config.repo || 'node'; + } + + get username() { + return this.config.username; + } + + secureTagRelease() { + const { version, isLTS, ltsCodename } = this; + + const secureTag = path.join( + __dirname, + '../node_modules/.bin/git-secure-tag' + (isWindows ? '.cmd' : '') + ); + + const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)'; + const secureTagOptions = [ + `v${version}`, + this.getCommitSha(), + '-sm', + `"${this.date} Node.js v${version} ${releaseInfo} Release"` + ]; + + return runSync(secureTag, secureTagOptions); + } + + // 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 } = this; + + // Update node_version.h for next patch release. + const filePath = path.resolve('src', 'node_version.h'); + const data = await fs.readFile(filePath, 'utf8'); + const arr = data.split('\n'); + + const patchVersion = versionComponents.patch + 1; + arr.forEach((line, idx) => { + if (line.includes('#define NODE_PATCH_VERSION')) { + arr[idx] = `#define NODE_PATCH_VERSION ${patchVersion}`; + } else if (line.includes('#define NODE_VERSION_IS_RELEASE')) { + arr[idx] = '#define NODE_VERSION_IS_RELEASE 0'; + } + }); + + await fs.writeFile(filePath, arr.join('\n')); + + const workingOnVersion = + `${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; + + // Create 'Working On' commit. + runSync('git', ['add', filePath]); + return runSync('git', [ + 'commit', + '-m', + `Working on ${workingOnVersion}`, + '-m', + `PR-URL: https://github.com/nodejs/node/pull/${prid}` + ]); + } + + async mergeProposalBranch() { + const { stagingBranch, versionComponents, version } = this; + + const releaseBranch = `v${versionComponents.major}.x`; + const proposalBranch = `v${version}-proposal`; + + runSync('git', ['checkout', releaseBranch]); + runSync('git', ['merge', '--ff-only', proposalBranch]); + runSync('git', ['push', 'upstream', releaseBranch]); + runSync('git', ['checkout', stagingBranch]); + runSync('git', ['rebase', releaseBranch]); + runSync('git', ['push', 'upstream', stagingBranch]); + } + + pushReleaseTag() { + const { version } = this; + + const tagVersion = `v${version}`; + return runSync('git', ['push', 'upstream', tagVersion]); + } + + promoteAndSignRelease(keyPath) { + return runSync('./tools/release.sh', ['-i', keyPath]); + } + + async cherryPickToMaster() { + // Since we've committed the Working On commit, + // the release commit will be 1 removed from + // tip-of-tree (e.g HEAD~1). + const releaseCommitSha = this.getCommitSha(1); + runSync('git', ['checkout', 'master']); + + // There will be conflicts. + runSync('git', ['cherry-pick', releaseCommitSha]); + // TODO(codebytere): gracefully handle conflicts and + // wait for the releaser to resolve. + } +} + +module.exports = ReleasePromotion; diff --git a/package.json b/package.json index 6534c461..49734f5e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "figures": "^3.2.0", "fs-extra": "^9.0.0", "ghauth": "^4.0.0", + "git-secure-tag": "^2.3.1", "inquirer": "^7.1.0", "listr": "^0.14.3", "listr-input": "^0.2.1", From 15c4cb0bc17ad676d7bd620d831e9102856ee3a6 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 9 Apr 2020 10:33:43 -0700 Subject: [PATCH 02/47] Move newVersion to named arg --- components/git/release.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index f88a90a3..f5d19890 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -26,17 +26,22 @@ const releaseOptions = { security: { describe: 'Demarcate the new security release as a security release', type: 'boolean' + }, + newVersion: { + describe: 'Version number of the release to be prepared', + type: 'string' } }; function builder(yargs) { return yargs - .options(releaseOptions).positional('newVersion', { - describe: 'Version number of the release to be prepared' - }).positional('prid', { - describe: 'PR number of the release to be promoted' + .options(releaseOptions).positional('prid', { + describe: 'PR number of the release to be promoted', + type: 'number' }) - .example('git node release --prepare 1.2.3', + .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 --promote 12345', 'Promote a prepared release of Node.js with PR #12345'); @@ -68,7 +73,7 @@ function release(state, argv) { } module.exports = { - command: 'release [newVersion|prid|options]', + command: 'release [prid|options]', describe: 'Manage an in-progress release or start a new one.', builder, From 0ec332753f8a27d6b213f890eaed9a41101f0df7 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 9 Apr 2020 10:33:56 -0700 Subject: [PATCH 03/47] Split out promotion verification steps --- lib/promote_release.js | 60 ++++++++++++++++++++++++++++-------------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index ed46ee45..ccf83013 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -25,42 +25,55 @@ class ReleasePromotion { } async promote() { - const { version, prid, cli } = this; - // In the promotion stage, we can pull most relevant data // from the release commit created in the preparation stage. await this.parseDataFromReleaseCommit(); + const { prid, cli, version } = this; + // Verify that PR is ready to promote. - cli.startSpinner('Verifying PR promotion readiness'); const { jenkinsReady, githubCIReady, isApproved } = await this.verifyPRAttributes(); + + cli.startSpinner('Verifying Jenkins CI status'); if (!jenkinsReady) { - cli.stopSpinner(`Jenkins CI is failing for #${prid}`); + 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}`); return; } - } else if (!githubCIReady) { - cli.stopSpinner(`GitHub CI is failing for #${prid}`); + } + 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}`); return; } - } else if (!isApproved) { - cli.stopSpinner(`#${prid} does not have sufficient approvals`); + } + 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}`); return; } } - cli.stopSpinner(`The release PR for ${version} is ready to promote!`); + cli.stopSpinner(`#${prid} has necessary approvals`); // Create and sign the release tag. const shouldTagAndSignRelease = await cli.prompt( @@ -74,7 +87,7 @@ class ReleasePromotion { // Set up for next release. cli.startSpinner('Setting up for next release'); await this.setupForNextRelease(); - cli.startSpinner('Successfully set up for next release'); + cli.stopSpinner('Successfully set up for next release'); const shouldMergeProposalBranch = await cli.prompt( 'Merge proposal branch into staging branch?'); @@ -86,7 +99,7 @@ class ReleasePromotion { // Merge vX.Y.Z-proposal into vX.x. cli.startSpinner('Merging proposal branch'); await this.mergeProposalBranch(); - cli.startSpinner('Merged proposal branch'); + cli.stopSpinner('Merged proposal branch'); // Cherry pick release commit to master. const shouldCherryPick = await cli.prompt( @@ -95,7 +108,17 @@ class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } - await this.cherryPickToMaster(); + this.cherryPickToMaster(); + + // There will be cherry-pick conflicts the Releaser will + // need to resolve, so confirm they've been resolved before + // proceeding with next steps. + const didResolveConflicts = await cli.prompt( + 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true }); + if (!didResolveConflicts) { + cli.warn(`Aborting release promotion for version ${version}`); + return; + } // Push release tag. const shouldPushTag = await cli.prompt('Push release tag?', @@ -142,7 +165,7 @@ class ReleasePromotion { const checker = new PRChecker(cli, data, { prid, owner, repo }); const jenkinsReady = checker.checkJenkinsCI(); const githubCIReady = checker.checkGitHubCI(); - const isApproved = checker.checkReviewsAndWait(false /* checkComments */); + const isApproved = checker.checkReviewsAndWait(new Date(), false); return { jenkinsReady, @@ -160,7 +183,7 @@ class ReleasePromotion { const components = releaseCommitMessage.split(' '); // Parse out release date. - if (!/\d{4}-\d{2}-\d{2}/.match(components[0])) { + if (!components[0].match(/\d{4}-\d{2}-\d{2}/)) { cli.error(`Release commit contains invalid date: ${components[0]}`); return; } @@ -283,17 +306,14 @@ class ReleasePromotion { return runSync('./tools/release.sh', ['-i', keyPath]); } - async cherryPickToMaster() { - // Since we've committed the Working On commit, - // the release commit will be 1 removed from - // tip-of-tree (e.g HEAD~1). + cherryPickToMaster() { + // Since we've committed the Working On commit, the release + // commit will be 1 removed from tip-of-tree (e.g HEAD~1). const releaseCommitSha = this.getCommitSha(1); runSync('git', ['checkout', 'master']); // There will be conflicts. runSync('git', ['cherry-pick', releaseCommitSha]); - // TODO(codebytere): gracefully handle conflicts and - // wait for the releaser to resolve. } } From a1c3ef1c76f7dff9576fad23aaa02b6cf2571559 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Tue, 14 Apr 2020 10:58:14 -0700 Subject: [PATCH 04/47] Fix verification spinners --- lib/promote_release.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index ccf83013..44cf947c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -47,8 +47,9 @@ class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } + } else { + cli.stopSpinner('Jenkins CI is passing'); } - cli.stopSpinner('Jenkins CI is passing'); cli.startSpinner('Verifying GitHub CI status'); if (!githubCIReady) { @@ -59,8 +60,9 @@ class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } + } else { + cli.stopSpinner('GitHub CI is passing'); } - cli.stopSpinner('GitHub CI is passing'); cli.startSpinner('Verifying PR approval status'); if (!isApproved) { @@ -72,8 +74,9 @@ class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } + } else { + cli.stopSpinner(`#${prid} has necessary approvals`); } - cli.stopSpinner(`#${prid} has necessary approvals`); // Create and sign the release tag. const shouldTagAndSignRelease = await cli.prompt( From 61dbeb78a0bcfa21ed4ed83a49f119b56d7f9408 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Tue, 14 Apr 2020 11:01:34 -0700 Subject: [PATCH 05/47] Verify ncurc is set up --- components/git/release.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index f5d19890..d64ed182 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -110,12 +110,19 @@ async function main(state, argv, cli, dir) { const info = new TeamInfo(cli, request, 'nodejs', RELEASERS); const releasers = await info.getMembers(); - if (!releasers.some(r => r.login === release.username)) { - cli.stopSpinner( - `${release.username} is not a Releaser; aborting release`); + 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.some(r => r.login === release.username)) { + cli.stopSpinner( + `${release.username} is not a Releaser; aborting release`); + return; + } + cli.stopSpinner('Verified Releaser status'); } - cli.stopSpinner('Verified Releaser status'); return release.promote(); } From ea29f87ccb897997b819089eb8b41af002096957 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Tue, 14 Apr 2020 11:02:56 -0700 Subject: [PATCH 06/47] Properly trim getCommitSha result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Michaƫl Zasso --- lib/promote_release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 44cf947c..0d7bef06 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -215,7 +215,7 @@ class ReleasePromotion { } getCommitSha(position = 0) { - return runSync('git', ['rev-parse', `HEAD~${position}`]); + return runSync('git', ['rev-parse', `HEAD~${position}`]).trim(); } get owner() { From c0db1cecacd941e553e344940a546626629661be Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Mon, 4 May 2020 08:52:05 -0700 Subject: [PATCH 07/47] Address some feedback --- lib/promote_release.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 0d7bef06..6c2a2e57 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -116,6 +116,13 @@ class ReleasePromotion { // There will be cherry-pick conflicts the Releaser will // need to resolve, so confirm they've been resolved before // proceeding with next steps. + cli.separator(); + cli.info(`After cherry-picking: + * The version macros in src/node_version.h should contain whatever values + were previously on master. + * NODE_VERSION_IS_RELEASE should be 0. + `); + cli.separator(); const didResolveConflicts = await cli.prompt( 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true }); if (!didResolveConflicts) { @@ -186,11 +193,12 @@ class ReleasePromotion { const components = releaseCommitMessage.split(' '); // Parse out release date. - if (!components[0].match(/\d{4}-\d{2}-\d{2}/)) { + const match = components[0].match(/\d{4}-\d{2}-\d{2}/); + if (!match) { cli.error(`Release commit contains invalid date: ${components[0]}`); return; } - this.date = components[0]; + this.date = match[0]; // Parse out release version. const version = semver.clean(components[2]); @@ -243,7 +251,7 @@ class ReleasePromotion { `v${version}`, this.getCommitSha(), '-sm', - `"${this.date} Node.js v${version} ${releaseInfo} Release"` + `${this.date} Node.js v${version} ${releaseInfo} Release` ]; return runSync(secureTag, secureTagOptions); @@ -271,7 +279,7 @@ class ReleasePromotion { await fs.writeFile(filePath, arr.join('\n')); const workingOnVersion = - `${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; + `v${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; // Create 'Working On' commit. runSync('git', ['add', filePath]); From 7d23d70a6261d37aa669368c3c543e8b8b50fbd5 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Mon, 4 May 2020 08:58:11 -0700 Subject: [PATCH 08/47] Ensure synced with upstream/master --- lib/promote_release.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index 6c2a2e57..06dd1d04 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -323,6 +323,9 @@ class ReleasePromotion { const releaseCommitSha = this.getCommitSha(1); runSync('git', ['checkout', 'master']); + // Pull master from upstream, in case it's not up-to-date. + runSync('git', ['pull', '--rebase', 'upstream', 'master']); + // There will be conflicts. runSync('git', ['cherry-pick', releaseCommitSha]); } From 692c6b725246319733e5433ad044f4fc06862fb8 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Mon, 4 May 2020 09:00:21 -0700 Subject: [PATCH 09/47] fix: run release script asynchronously --- lib/promote_release.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 06dd1d04..ed08b7a1 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -5,7 +5,7 @@ const { promises: fs } = require('fs'); const semver = require('semver'); const { getMergedConfig } = require('./config'); -const { runSync } = require('./run'); +const { runSync, runAsync } = require('./run'); const auth = require('./auth'); const PRData = require('./pr_data'); const PRChecker = require('./pr_checker'); @@ -101,7 +101,7 @@ class ReleasePromotion { // Merge vX.Y.Z-proposal into vX.x. cli.startSpinner('Merging proposal branch'); - await this.mergeProposalBranch(); + this.mergeProposalBranch(); cli.stopSpinner('Merged proposal branch'); // Cherry pick release commit to master. @@ -151,7 +151,7 @@ class ReleasePromotion { const keyPath = await cli.prompt( `Please enter the path to your ssh key (Default ${defaultKeyPath}): `, { questionType: 'input', defaultAnswer: defaultKeyPath }); - this.promoteAndSignRelease(keyPath); + await this.promoteAndSignRelease(keyPath); cli.separator(); cli.ok(`Release promotion for ${version} complete.\n`); @@ -292,7 +292,7 @@ class ReleasePromotion { ]); } - async mergeProposalBranch() { + mergeProposalBranch() { const { stagingBranch, versionComponents, version } = this; const releaseBranch = `v${versionComponents.major}.x`; @@ -313,8 +313,8 @@ class ReleasePromotion { return runSync('git', ['push', 'upstream', tagVersion]); } - promoteAndSignRelease(keyPath) { - return runSync('./tools/release.sh', ['-i', keyPath]); + async promoteAndSignRelease(keyPath) { + await runAsync('./tools/release.sh', ['-i', keyPath]); } cherryPickToMaster() { From c1ab158b0bbf69a814df1958162d041bdd2902ec Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 20 May 2020 11:52:23 -0700 Subject: [PATCH 10/47] Run secureTagRelease() asynchronously --- lib/promote_release.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index ed08b7a1..b4d82b07 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -238,7 +238,7 @@ class ReleasePromotion { return this.config.username; } - secureTagRelease() { + async secureTagRelease() { const { version, isLTS, ltsCodename } = this; const secureTag = path.join( @@ -254,7 +254,7 @@ class ReleasePromotion { `${this.date} Node.js v${version} ${releaseInfo} Release` ]; - return runSync(secureTag, secureTagOptions); + await runAsync(secureTag, secureTagOptions); } // Set up the branch so that nightly builds are produced with the next From ddfe6efdfc240ef352426869430e0e0a3f9d05f0 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Tue, 26 May 2020 08:49:05 -0700 Subject: [PATCH 11/47] Run cherry-pick asynchronously --- lib/promote_release.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index b4d82b07..16c8023f 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -111,7 +111,7 @@ class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } - this.cherryPickToMaster(); + await this.cherryPickToMaster(); // There will be cherry-pick conflicts the Releaser will // need to resolve, so confirm they've been resolved before @@ -317,7 +317,7 @@ class ReleasePromotion { await runAsync('./tools/release.sh', ['-i', keyPath]); } - cherryPickToMaster() { + async cherryPickToMaster() { // Since we've committed the Working On commit, the release // commit will be 1 removed from tip-of-tree (e.g HEAD~1). const releaseCommitSha = this.getCommitSha(1); @@ -327,7 +327,7 @@ class ReleasePromotion { runSync('git', ['pull', '--rebase', 'upstream', 'master']); // There will be conflicts. - runSync('git', ['cherry-pick', releaseCommitSha]); + await runAsync('git', ['cherry-pick', releaseCommitSha]); } } From 3377b44ecfde06a108b73ff2795fa0e16cb2a6e5 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Tue, 26 May 2020 08:50:26 -0700 Subject: [PATCH 12/47] Remove unneccessary quotes --- lib/promote_release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 16c8023f..66502c76 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -246,7 +246,7 @@ class ReleasePromotion { '../node_modules/.bin/git-secure-tag' + (isWindows ? '.cmd' : '') ); - const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)'; + const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; const secureTagOptions = [ `v${version}`, this.getCommitSha(), From a08566dfb28e72674124c30d0c528ecae7eca656 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 10:18:42 +0200 Subject: [PATCH 13/47] nits --- components/git/release.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index 4228ed2f..2dbdffcf 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -89,10 +89,8 @@ function release(state, argv) { } async function main(state, argv, cli, dir) { - let release; - if (state === PREPARE) { - release = new ReleasePreparation(argv, cli, dir); + const release = new ReleasePreparation(argv, cli, dir); if (release.warnForWrongBranch()) return; @@ -110,7 +108,7 @@ async function main(state, argv, cli, dir) { return release.prepare(); } else if (state === PROMOTE) { - release = new ReleasePromotion(argv, cli, dir); + const release = new ReleasePromotion(argv, cli, dir); cli.startSpinner('Verifying Releaser status'); const credentials = await auth({ github: true }); @@ -123,14 +121,12 @@ async function main(state, argv, cli, dir) { cli.info( 'Username was undefined - do you have your .ncurc set up correctly?'); return; - } else { - if (!releasers.some(r => r.login === release.username)) { - cli.stopSpinner( - `${release.username} is not a Releaser; aborting release`); - return; - } - cli.stopSpinner('Verified Releaser status'); + } else if (releasers.every(r => r.login !== release.username)) { + cli.stopSpinner( + `${release.username} is not a Releaser; aborting release`); + return; } + cli.stopSpinner('Verified Releaser status'); return release.promote(); } From 0229ccf4a6f107270f0e09defed7b67767813202 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 10:20:25 +0200 Subject: [PATCH 14/47] esm --- lib/promote_release.js | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 66502c76..581c50fe 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -1,19 +1,17 @@ -'use strict'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import semver from 'semver'; -const path = require('path'); -const { promises: fs } = require('fs'); -const semver = require('semver'); - -const { getMergedConfig } = require('./config'); -const { runSync, runAsync } = require('./run'); -const auth = require('./auth'); -const PRData = require('./pr_data'); -const PRChecker = require('./pr_checker'); -const Request = require('./request'); +import { getMergedConfig } from './config.js'; +import { runSync } from './run.js'; +import auth from './auth.js'; +import PRData from './pr_data.js'; +import PRChecker from './pr_checker.js'; +import Request from './request.js'; const isWindows = process.platform === 'win32'; -class ReleasePromotion { +export default class ReleasePromotion { constructor(argv, cli, dir) { this.cli = cli; this.dir = dir; @@ -330,5 +328,3 @@ class ReleasePromotion { await runAsync('git', ['cherry-pick', releaseCommitSha]); } } - -module.exports = ReleasePromotion; From ba237815770220569b6629131d960a2752a30548 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 11:15:41 +0200 Subject: [PATCH 15/47] some refactor + more checks --- lib/promote_release.js | 171 +++++++++++++++++++---------------------- 1 file changed, 77 insertions(+), 94 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 581c50fe..003e703f 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -1,25 +1,21 @@ import path from 'node:path'; import fs from 'node:fs/promises'; import semver from 'semver'; +import * as gst from 'git-secure-tag'; -import { getMergedConfig } from './config.js'; -import { runSync } from './run.js'; +import { forceRunAsync } from './run.js'; import auth from './auth.js'; import PRData from './pr_data.js'; import PRChecker from './pr_checker.js'; import Request from './request.js'; +import Session from './session.js'; -const isWindows = process.platform === 'win32'; - -export default class ReleasePromotion { +export default class ReleasePromotion extends Session { constructor(argv, cli, dir) { - this.cli = cli; - this.dir = dir; + super(cli, dir, argv.prid); this.isLTS = false; - this.prid = argv.prid; this.ltsCodename = ''; this.date = ''; - this.config = getMergedConfig(this.dir); } async promote() { @@ -83,7 +79,7 @@ export default class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } - this.secureTagRelease(); + await this.secureTagRelease(); // Set up for next release. cli.startSpinner('Setting up for next release'); @@ -99,17 +95,17 @@ export default class ReleasePromotion { // Merge vX.Y.Z-proposal into vX.x. cli.startSpinner('Merging proposal branch'); - this.mergeProposalBranch(); + await this.mergeProposalBranch(); cli.stopSpinner('Merged proposal branch'); // Cherry pick release commit to master. const shouldCherryPick = await cli.prompt( - 'Cherry-pick release commit to master?', { defaultAnswer: true }); + 'Cherry-pick release commit to the default branch?', { defaultAnswer: true }); if (!shouldCherryPick) { cli.warn(`Aborting release promotion for version ${version}`); return; } - await this.cherryPickToMaster(); + await this.cherryPickToDefaultBranch(); // There will be cherry-pick conflicts the Releaser will // need to resolve, so confirm they've been resolved before @@ -135,7 +131,7 @@ export default class ReleasePromotion { cli.warn(`Aborting release promotion for version ${version}`); return; } - this.pushReleaseTag(); + await this.pushReleaseTag(); // Promote and sign the release builds. const shouldPromote = await cli.prompt('Promote and sign release builds?', @@ -145,6 +141,7 @@ export default class ReleasePromotion { 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}): `, @@ -155,10 +152,11 @@ export default class ReleasePromotion { cli.ok(`Release promotion for ${version} 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' + - 'Finally, proceed to Twitter and announce the new release!'); + ` 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' + + ' 5. Tag @nodejs-social-team on #nodejs-release Slack channel.\n'); } async verifyPRAttributes() { @@ -185,74 +183,61 @@ export default class ReleasePromotion { async parseDataFromReleaseCommit() { const { cli } = this; - const releaseCommitMessage = runSync( - 'git', ['log', '-n', '1', '--pretty=format:\'%s\'']).trim(); - - const components = releaseCommitMessage.split(' '); + const data = await forceRunAsync('git', ['log', '-1', '--pretty=format:%H+%s']); + this.releaseCommitSha = data.slice(0, 40); + const releaseCommitMessage = data.slice(41); // Parse out release date. - const match = components[0].match(/\d{4}-\d{2}-\d{2}/); - if (!match) { - cli.error(`Release commit contains invalid date: ${components[0]}`); + if (!/^\d{4}-\d{2}-\d{2}, Version \d/.test(releaseCommitMessage)) { + cli.error(`Invalid Release commit message: ${releaseCommitMessage}`); return; } - this.date = match[0]; + this.date = releaseCommitMessage.slice(0, 10); + if (this.date !== new Date().toISOString().slice(0, 10)) { + cli.warn('The release date does not match the system date for today.'); + await cli.prompt('Do you want to proceed?', { defaultAnswer: false }); + } // Parse out release version. - const version = semver.clean(components[2]); - if (!semver.valid(version)) { - cli.error(`Release commit contains invalid semantic version: ${version}`); + const versionString = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20)); + const version = semver.parse(versionString); + if (!version) { + cli.error(`Release commit contains invalid semantic version: ${versionString}`); return; } - this.version = version; - this.stagingBranch = `v${semver.major(version)}.x-staging`; + const { major, minor, patch } = version; + this.stagingBranch = `v${major}.x-staging`; this.versionComponents = { - major: semver.major(version), - minor: semver.minor(version), - patch: semver.patch(version) + major, + minor, + patch }; // Parse out LTS status and codename. - if (components.length === 5) { + if (!releaseCommitMessage.endsWith(' (Current)')) { + const match = /'([^']+)' \(LTS\)$/.exec(releaseCommitMessage); + if (match == null) { + cli.error('Invalid release commit, it should match either Current or LTS release format'); + return; + } this.isLTS = true; - this.ltsCodename = components[3]; + this.ltsCodename = match[1]; } } - getCommitSha(position = 0) { - return runSync('git', ['rev-parse', `HEAD~${position}`]).trim(); - } - - get owner() { - return this.config.owner || 'nodejs'; - } - - get repo() { - return this.config.repo || 'node'; - } - - get username() { - return this.config.username; - } - async secureTagRelease() { const { version, isLTS, ltsCodename } = this; - const secureTag = path.join( - __dirname, - '../node_modules/.bin/git-secure-tag' + (isWindows ? '.cmd' : '') - ); - const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; - const secureTagOptions = [ - `v${version}`, - this.getCommitSha(), - '-sm', - `${this.date} Node.js v${version} ${releaseInfo} Release` - ]; - - await runAsync(secureTag, secureTagOptions); + + await new Promise((resolve, reject) => { + const api = new gst.API(process.cwd()); + api.sign(`v${version}`, this.releaseCommitSha, { + insecure: false, + m: `${this.date} Node.js v${version} ${releaseInfo} Release` + }, (err) => err ? reject(err) : resolve()); + }); } // Set up the branch so that nightly builds are produced with the next @@ -262,26 +247,28 @@ export default class ReleasePromotion { // Update node_version.h for next patch release. const filePath = path.resolve('src', 'node_version.h'); - const data = await fs.readFile(filePath, 'utf8'); - const arr = data.split('\n'); + const nodeVersionFile = await fs.open(filePath, 'r+'); const patchVersion = versionComponents.patch + 1; - arr.forEach((line, idx) => { - if (line.includes('#define NODE_PATCH_VERSION')) { - arr[idx] = `#define NODE_PATCH_VERSION ${patchVersion}`; - } else if (line.includes('#define NODE_VERSION_IS_RELEASE')) { - arr[idx] = '#define NODE_VERSION_IS_RELEASE 0'; + let cursor = 0; + for await (const line of nodeVersionFile.readLines()) { + cursor += line.length + 1; + if (line === `#define NODE_PATCH_VERSION ${versionComponents.patch}`) { + await nodeVersionFile.write(`${patchVersion}`, cursor - 1, 'ascii'); + } else if (line === '#define NODE_VERSION_IS_RELEASE 1') { + await nodeVersionFile.write('0', cursor - 1, 'ascii'); + break; } - }); + } - await fs.writeFile(filePath, arr.join('\n')); + await nodeVersionFile.close(); const workingOnVersion = `v${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; // Create 'Working On' commit. - runSync('git', ['add', filePath]); - return runSync('git', [ + await forceRunAsync('git', ['add', filePath]); + return forceRunAsync('git', [ 'commit', '-m', `Working on ${workingOnVersion}`, @@ -291,40 +278,36 @@ export default class ReleasePromotion { } mergeProposalBranch() { - const { stagingBranch, versionComponents, version } = this; + const { stagingBranch, versionComponents } = this; const releaseBranch = `v${versionComponents.major}.x`; - const proposalBranch = `v${version}-proposal`; - - runSync('git', ['checkout', releaseBranch]); - runSync('git', ['merge', '--ff-only', proposalBranch]); - runSync('git', ['push', 'upstream', releaseBranch]); - runSync('git', ['checkout', stagingBranch]); - runSync('git', ['rebase', releaseBranch]); - runSync('git', ['push', 'upstream', stagingBranch]); + + return Promise.all([ + forceRunAsync('git', ['push', this.upstream, `HEAD:${releaseBranch}`]), + forceRunAsync('git', ['push', this.upstream, `HEAD:${stagingBranch}`]) + ]); } pushReleaseTag() { const { version } = this; const tagVersion = `v${version}`; - return runSync('git', ['push', 'upstream', tagVersion]); + return forceRunAsync('git', ['push', this.upstream, tagVersion]); } async promoteAndSignRelease(keyPath) { - await runAsync('./tools/release.sh', ['-i', keyPath]); + await forceRunAsync('./tools/release.sh', ['-i', keyPath]); } - async cherryPickToMaster() { - // Since we've committed the Working On commit, the release - // commit will be 1 removed from tip-of-tree (e.g HEAD~1). - const releaseCommitSha = this.getCommitSha(1); - runSync('git', ['checkout', 'master']); + async cherryPickToDefaultBranch() { + const releaseCommitSha = this.releaseCommitSha; + await forceRunAsync('git', ['checkout', 'main']); // Pull master from upstream, in case it's not up-to-date. - runSync('git', ['pull', '--rebase', 'upstream', 'master']); + await forceRunAsync('git', ['pull', '--rebase', this.upstream, 'main']); // There will be conflicts. - await runAsync('git', ['cherry-pick', releaseCommitSha]); + // TODO: auto-fix obvious conflicts and/or ensure they are correctly fixed. + await forceRunAsync('git', ['cherry-pick', releaseCommitSha]); } } From 8ef8c41817d2424a68f73b178d8a50b2247a155c Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 11:21:18 +0200 Subject: [PATCH 16/47] fixup! Merge branch 'main' of https://github.com/nodejs/node-core-utils --- package.json | 43 ++++++++++++++++++++++--------------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 1714c944..efd0ac25 100644 --- a/package.json +++ b/package.json @@ -34,26 +34,28 @@ ], "license": "MIT", "dependencies": { - "branch-diff": "^1.8.1", - "chalk": "^4.0.0", - "changelog-maker": "^2.4.0", - "cheerio": "^1.0.0-rc.3", - "clipboardy": "^2.3.0", - "core-validate-commit": "^3.13.1", - "execa": "^4.0.1", - "figures": "^3.2.0", - "fs-extra": "^9.0.0", - "ghauth": "^4.0.0", + "@listr2/prompt-adapter-enquirer": "^2.0.10", + "@node-core/caritat": "^1.6.0", + "@pkgjs/nv": "^0.2.2", + "branch-diff": "^3.0.4", + "chalk": "^5.3.0", + "changelog-maker": "^4.1.1", + "cheerio": "^1.0.0-rc.12", + "clipboardy": "^4.0.0", + "core-validate-commit": "^4.0.0", + "figures": "^6.1.0", + "ghauth": "^6.0.5", "git-secure-tag": "^2.3.1", - "inquirer": "^7.1.0", - "listr": "^0.14.3", - "listr-input": "^0.2.1", - "lodash": "^4.17.15", - "node-fetch": "^2.6.0", - "ora": "^4.0.4", - "replace-in-file": "^6.0.0", - "rimraf": "^3.0.2", - "yargs": "^15.3.1" + "inquirer": "^9.3.2", + "js-yaml": "^4.1.0", + "listr2": "^8.2.3", + "lodash": "^4.17.21", + "log-symbols": "^6.0.0", + "ora": "^8.0.1", + "replace-in-file": "^8.0.2", + "undici": "^6.19.2", + "which": "^4.0.0", + "yargs": "^17.7.2" }, "devDependencies": { "@reporters/github": "^1.7.0", @@ -64,6 +66,5 @@ "eslint-plugin-n": "^16.6.2", "eslint-plugin-promise": "^6.4.0", "sinon": "^18.0.0" - }, - "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" + } } From 8d02a41ddfa7a422460cbf11e54be4b9639916df Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 11:32:20 +0200 Subject: [PATCH 17/47] throw on error to get non-zero exit code --- lib/promote_release.js | 37 +++++++++++++++++++++---------------- lib/session.js | 4 ++++ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 003e703f..da5b601c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -39,7 +39,7 @@ export default class ReleasePromotion extends Session { const proceed = await cli.prompt('Do you want to proceed?'); if (!proceed) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } } else { cli.stopSpinner('Jenkins CI is passing'); @@ -52,7 +52,7 @@ export default class ReleasePromotion extends Session { const proceed = await cli.prompt('Do you want to proceed?'); if (!proceed) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } } else { cli.stopSpinner('GitHub CI is passing'); @@ -66,7 +66,7 @@ export default class ReleasePromotion extends Session { const proceed = await cli.prompt('Do you want to proceed?'); if (!proceed) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } } else { cli.stopSpinner(`#${prid} has necessary approvals`); @@ -77,7 +77,7 @@ export default class ReleasePromotion extends Session { 'Tag and sign the release?'); if (!shouldTagAndSignRelease) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } await this.secureTagRelease(); @@ -90,7 +90,7 @@ export default class ReleasePromotion extends Session { 'Merge proposal branch into staging branch?'); if (!shouldMergeProposalBranch) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } // Merge vX.Y.Z-proposal into vX.x. @@ -103,7 +103,7 @@ export default class ReleasePromotion extends Session { 'Cherry-pick release commit to the default branch?', { defaultAnswer: true }); if (!shouldCherryPick) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } await this.cherryPickToDefaultBranch(); @@ -121,7 +121,7 @@ export default class ReleasePromotion extends Session { 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true }); if (!didResolveConflicts) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } // Push release tag. @@ -129,7 +129,7 @@ export default class ReleasePromotion extends Session { { defaultAnswer: true }); if (!shouldPushTag) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } await this.pushReleaseTag(); @@ -138,7 +138,7 @@ export default class ReleasePromotion extends Session { { defaultAnswer: true }); if (!shouldPromote) { cli.warn(`Aborting release promotion for version ${version}`); - return; + throw new Error('Aborted'); } // TODO: move this to .ncurc @@ -183,19 +183,24 @@ export default class ReleasePromotion extends Session { async parseDataFromReleaseCommit() { const { cli } = this; - const data = await forceRunAsync('git', ['log', '-1', '--pretty=format:%H+%s']); + const data = await forceRunAsync('git', ['--no-pager', 'log', '-1', '--pretty=format:%H+%s'], { + captureStdout: true + }); this.releaseCommitSha = data.slice(0, 40); const releaseCommitMessage = data.slice(41); // Parse out release date. if (!/^\d{4}-\d{2}-\d{2}, Version \d/.test(releaseCommitMessage)) { cli.error(`Invalid Release commit message: ${releaseCommitMessage}`); - return; + throw new Error('Aborted'); } this.date = releaseCommitMessage.slice(0, 10); - if (this.date !== new Date().toISOString().slice(0, 10)) { - cli.warn('The release date does not match the system date for today.'); - await cli.prompt('Do you want to proceed?', { defaultAnswer: false }); + const systemDate = new Date().toISOString().slice(0, 10); + if (this.date !== systemDate) { + cli.warn(`The release date (${this.date}) does not match the system date for today (${systemDate}).`); + if (!await cli.prompt('Do you want to proceed?', { defaultAnswer: false })) { + throw new Error('Aborted'); + } } // Parse out release version. @@ -203,7 +208,7 @@ export default class ReleasePromotion extends Session { const version = semver.parse(versionString); if (!version) { cli.error(`Release commit contains invalid semantic version: ${versionString}`); - return; + throw new Error('Aborted'); } const { major, minor, patch } = version; @@ -219,7 +224,7 @@ export default class ReleasePromotion extends Session { const match = /'([^']+)' \(LTS\)$/.exec(releaseCommitMessage); if (match == null) { cli.error('Invalid release commit, it should match either Current or LTS release format'); - return; + throw new Error('Aborted'); } this.isLTS = true; this.ltsCodename = match[1]; diff --git a/lib/session.js b/lib/session.js index 937de253..c6bba9a6 100644 --- a/lib/session.js +++ b/lib/session.js @@ -87,6 +87,10 @@ export default class Session { return this.config.branch; } + get username() { + return this.config.username; + } + get readme() { return this.config.readme; } From 15c03357351377f73781333cd8663646ae4389ea Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 11:32:50 +0200 Subject: [PATCH 18/47] lint --- lib/promote_release.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index da5b601c..b7b2afbb 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -197,8 +197,10 @@ export default class ReleasePromotion extends Session { this.date = releaseCommitMessage.slice(0, 10); const systemDate = new Date().toISOString().slice(0, 10); if (this.date !== systemDate) { - cli.warn(`The release date (${this.date}) does not match the system date for today (${systemDate}).`); - if (!await cli.prompt('Do you want to proceed?', { defaultAnswer: false })) { + cli.warn( + `The release date (${this.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'); } } From ce72380ecba87e87136d87d2468c5fe6c6ec7e28 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 11:49:54 +0200 Subject: [PATCH 19/47] validate CHANGELOG heading --- lib/promote_release.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index b7b2afbb..354a47b3 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -231,6 +231,31 @@ export default class ReleasePromotion extends Session { this.isLTS = true; this.ltsCodename = match[1]; } + + // Check if CHANGELOG show the correct releaser for the current release + const changeLogDiff = await forceRunAsync('git', [ + '--no-pager', 'diff', + `${this.releaseCommitSha}^..${this.releaseCommitSha}`, + '--', + `doc/changelogs/CHANGELOG_V${version.major}.md` + ], { captureStdout: true }); + 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'); + } + 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'); + } + } } async secureTagRelease() { From 519388c7f623b819e1392b9190b2706e1879c105 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 12:37:11 +0200 Subject: [PATCH 20/47] add support for PR URL --- components/git/release.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index 2dbdffcf..d6decff7 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -50,8 +50,8 @@ export function builder(yargs) { yargsInstance = yargs; return yargs .options(releaseOptions).positional('prid', { - describe: 'PR number of the release to be promoted', - type: 'number' + 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') @@ -89,6 +89,10 @@ function release(state, argv) { } async function main(state, argv, cli, dir) { + const prID = /^(?:https:\/\/github\.com\/nodejs\/node\/pull\/)?(\d+)$/.exec(argv.prid); + if (prID) { + argv.prid = Number(prID[1]); + } if (state === PREPARE) { const release = new ReleasePreparation(argv, cli, dir); From a1b1974f35c4fc6033314cc623aa90c6b487eb06 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 13:21:38 +0200 Subject: [PATCH 21/47] check if local HEAD is in sync with release proposal --- lib/promote_release.js | 70 +++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 21 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 354a47b3..872fdc5c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -19,19 +19,45 @@ export default class ReleasePromotion extends Session { } async promote() { + const { prid, cli } = this; + // In the promotion stage, we can pull most relevant data // from the release commit created in the preparation stage. - await this.parseDataFromReleaseCommit(); - - const { prid, cli, version } = this; - // Verify that PR is ready to promote. const { - jenkinsReady, githubCIReady, - isApproved + isApproved, + jenkinsReady, + releaseCommitSha } = await this.verifyPRAttributes(); + this.releaseCommitSha = releaseCommitSha; + + let localCloseIsClean = true; + const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'], { captureStdout: true }); + if (currentHEAD.trim() !== releaseCommitSha) { + cli.warn('Current HEAD is not the release commit'); + localCloseIsClean = false; + } + try { + await forceRunAsync('git', ['--no-pager', 'diff', '--exit-code'], { ignoreFailure: false }) + } catch { + cli.warn('Some local changes have not been committed'); + localCloseIsClean = false; + } + if (!localCloseIsClean) { + if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) { + await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha]); + await forceRunAsync('git', ['reset', releaseCommitSha, '--hard']); + } else { + cli.error('Local clone is not ready'); + throw new Error('Aborted'); + } + } + + await this.parseDataFromReleaseCommit(); + + const { version } = this; cli.startSpinner('Verifying Jenkins CI status'); if (!jenkinsReady) { cli.stopSpinner( @@ -168,26 +194,28 @@ export default class ReleasePromotion extends Session { const data = new PRData({ prid, owner, repo }, cli, request); await data.getAll(); - const checker = new PRChecker(cli, data, { prid, owner, repo }); + 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); return { - jenkinsReady, githubCIReady, - isApproved + isApproved, + jenkinsReady, + releaseCommitSha: data.commits.at(-1).commit.oid }; } async parseDataFromReleaseCommit() { - const { cli } = this; + const { cli, releaseCommitSha } = this; - const data = await forceRunAsync('git', ['--no-pager', 'log', '-1', '--pretty=format:%H+%s'], { + const releaseCommitMessage = await forceRunAsync('git', [ + '--no-pager', 'log', '-1', + releaseCommitSha, + '--pretty=format:%s'], { captureStdout: true }); - this.releaseCommitSha = data.slice(0, 40); - const releaseCommitMessage = data.slice(41); // Parse out release date. if (!/^\d{4}-\d{2}-\d{2}, Version \d/.test(releaseCommitMessage)) { @@ -206,10 +234,10 @@ export default class ReleasePromotion extends Session { } // Parse out release version. - const versionString = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20)); - const version = semver.parse(versionString); + this.version = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20)); + const version = semver.parse(this.version); if (!version) { - cli.error(`Release commit contains invalid semantic version: ${versionString}`); + cli.error(`Release commit contains invalid semantic version: ${this.version}`); throw new Error('Aborted'); } @@ -259,13 +287,13 @@ export default class ReleasePromotion extends Session { } async secureTagRelease() { - const { version, isLTS, ltsCodename } = this; + const { version, isLTS, ltsCodename, releaseCommitSha } = this; const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; await new Promise((resolve, reject) => { const api = new gst.API(process.cwd()); - api.sign(`v${version}`, this.releaseCommitSha, { + api.sign(`v${version}`, releaseCommitSha, { insecure: false, m: `${this.date} Node.js v${version} ${releaseInfo} Release` }, (err) => err ? reject(err) : resolve()); @@ -283,12 +311,12 @@ export default class ReleasePromotion extends Session { const patchVersion = versionComponents.patch + 1; let cursor = 0; - for await (const line of nodeVersionFile.readLines()) { + for await (const line of nodeVersionFile.readLines({ autoClose: false })) { cursor += line.length + 1; if (line === `#define NODE_PATCH_VERSION ${versionComponents.patch}`) { - await nodeVersionFile.write(`${patchVersion}`, cursor - 1, 'ascii'); + await nodeVersionFile.write(`${patchVersion}`, cursor - 2, 'ascii'); } else if (line === '#define NODE_VERSION_IS_RELEASE 1') { - await nodeVersionFile.write('0', cursor - 1, 'ascii'); + await nodeVersionFile.write('0', cursor - 2, 'ascii'); break; } } From 6db9959369f5122b593ce83cdca17e28db748f13 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 13:28:43 +0200 Subject: [PATCH 22/47] Use existing `tryResetBranch` instead of custom git command --- lib/promote_release.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 872fdc5c..0da8749c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -363,8 +363,7 @@ export default class ReleasePromotion extends Session { const releaseCommitSha = this.releaseCommitSha; await forceRunAsync('git', ['checkout', 'main']); - // Pull master from upstream, in case it's not up-to-date. - await forceRunAsync('git', ['pull', '--rebase', this.upstream, 'main']); + await this.tryResetBranch(); // There will be conflicts. // TODO: auto-fix obvious conflicts and/or ensure they are correctly fixed. From 3cc80b1bb6da7f8be880cd96476d3fba8353942c Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:08:02 +0200 Subject: [PATCH 23/47] auto fix node_version conflicts --- lib/promote_release.js | 106 ++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 39 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 0da8749c..b6e49d64 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -34,21 +34,23 @@ export default class ReleasePromotion extends Session { this.releaseCommitSha = releaseCommitSha; let localCloseIsClean = true; - const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'], { captureStdout: 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'); localCloseIsClean = false; } try { - await forceRunAsync('git', ['--no-pager', 'diff', '--exit-code'], { ignoreFailure: false }) + await forceRunAsync('git', ['--no-pager', 'diff', '--exit-code'], { ignoreFailure: false }); } catch { cli.warn('Some local changes have not been committed'); localCloseIsClean = false; } if (!localCloseIsClean) { if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) { - await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha]); - await forceRunAsync('git', ['reset', releaseCommitSha, '--hard']); + await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha], + { ignoreFailure: false }); + await forceRunAsync('git', ['reset', releaseCommitSha, '--hard'], { ignoreFailure: false }); } else { cli.error('Local clone is not ready'); throw new Error('Aborted'); @@ -133,15 +135,15 @@ export default class ReleasePromotion extends Session { } await this.cherryPickToDefaultBranch(); - // There will be cherry-pick conflicts the Releaser will + // Update `node_version.h` + await forceRunAsync('git', ['checkout', 'HEAD', '--', 'src/node_version.h'], + { ignoreFailure: false }); + + // 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(`After cherry-picking: - * The version macros in src/node_version.h should contain whatever values - were previously on master. - * NODE_VERSION_IS_RELEASE should be 0. - `); + cli.info('Resovle the conflicts and commit the result'); cli.separator(); const didResolveConflicts = await cli.prompt( 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true }); @@ -150,6 +152,20 @@ export default class ReleasePromotion extends Session { throw new Error('Aborted'); } + // 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.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}`)('\n')}`); + if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) { + throw new Error('Aborted'); + } + } + // Push release tag. const shouldPushTag = await cli.prompt('Push release tag?', { defaultAnswer: true }); @@ -207,22 +223,15 @@ export default class ReleasePromotion extends Session { }; } - async parseDataFromReleaseCommit() { - const { cli, releaseCommitSha } = this; - - const releaseCommitMessage = await forceRunAsync('git', [ - '--no-pager', 'log', '-1', - releaseCommitSha, - '--pretty=format:%s'], { - captureStdout: true - }); - + 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'); } - this.date = releaseCommitMessage.slice(0, 10); + data.date = releaseCommitMessage.slice(0, 10); const systemDate = new Date().toISOString().slice(0, 10); if (this.date !== systemDate) { cli.warn( @@ -234,16 +243,16 @@ export default class ReleasePromotion extends Session { } // Parse out release version. - this.version = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20)); - const version = semver.parse(this.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: ${this.version}`); + cli.error(`Release commit contains invalid semantic version: ${data.version}`); throw new Error('Aborted'); } const { major, minor, patch } = version; - this.stagingBranch = `v${major}.x-staging`; - this.versionComponents = { + data.stagingBranch = `v${major}.x-staging`; + data.versionComponents = { major, minor, patch @@ -256,17 +265,35 @@ export default class ReleasePromotion extends Session { cli.error('Invalid release commit, it should match either Current or LTS release format'); throw new Error('Aborted'); } - this.isLTS = true; - this.ltsCodename = match[1]; + data.isLTS = true; + data.ltsCodename = match[1]; } + return data; + } + + async parseDataFromReleaseCommit() { + const { cli, releaseCommitSha } = this; + + const releaseCommitMessage = await forceRunAsync('git', [ + '--no-pager', 'log', '-1', + releaseCommitSha, + '--pretty=format:%s'], { + captureStdout: true, + ignoreFailure: false + }); + + Object.assign( + this, + await this.validateReleaseCommit(releaseCommitMessage) + ); // Check if CHANGELOG show the correct releaser for the current release const changeLogDiff = await forceRunAsync('git', [ '--no-pager', 'diff', `${this.releaseCommitSha}^..${this.releaseCommitSha}`, '--', - `doc/changelogs/CHANGELOG_V${version.major}.md` - ], { captureStdout: true }); + `doc/changelogs/CHANGELOG_V${this.version.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'); @@ -327,14 +354,14 @@ export default class ReleasePromotion extends Session { `v${versionComponents.major}.${versionComponents.minor}.${patchVersion}`; // Create 'Working On' commit. - await forceRunAsync('git', ['add', filePath]); + await forceRunAsync('git', ['add', filePath], { ignoreFailure: false }); return forceRunAsync('git', [ 'commit', '-m', `Working on ${workingOnVersion}`, '-m', `PR-URL: https://github.com/nodejs/node/pull/${prid}` - ]); + ], { ignoreFailure: false }); } mergeProposalBranch() { @@ -343,8 +370,10 @@ export default class ReleasePromotion extends Session { const releaseBranch = `v${versionComponents.major}.x`; return Promise.all([ - forceRunAsync('git', ['push', this.upstream, `HEAD:${releaseBranch}`]), - forceRunAsync('git', ['push', this.upstream, `HEAD:${stagingBranch}`]) + forceRunAsync('git', ['push', this.upstream, `HEAD:${releaseBranch}`], + { ignoreFailure: false }), + forceRunAsync('git', ['push', this.upstream, `HEAD:${stagingBranch}`, + { ignoreFailure: false }]) ]); } @@ -352,21 +381,20 @@ export default class ReleasePromotion extends Session { const { version } = this; const tagVersion = `v${version}`; - return forceRunAsync('git', ['push', this.upstream, tagVersion]); + return forceRunAsync('git', ['push', this.upstream, tagVersion], { ignoreFailure: false }); } async promoteAndSignRelease(keyPath) { - await forceRunAsync('./tools/release.sh', ['-i', keyPath]); + await forceRunAsync('./tools/release.sh', ['-i', keyPath], { ignoreFailure: false }); } async cherryPickToDefaultBranch() { const releaseCommitSha = this.releaseCommitSha; - await forceRunAsync('git', ['checkout', 'main']); + await forceRunAsync('git', ['checkout', 'main'], { ignoreFailure: false }); await this.tryResetBranch(); // There will be conflicts. - // TODO: auto-fix obvious conflicts and/or ensure they are correctly fixed. - await forceRunAsync('git', ['cherry-pick', releaseCommitSha]); + await forceRunAsync('git', ['cherry-pick', releaseCommitSha], { ignoreFailure: false }); } } From 04b4d4d834d7efd79d2c76b60aca88eaf2a16942 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:08:16 +0200 Subject: [PATCH 24/47] add `--dryRun` mode to simplify testing --- components/git/release.js | 4 ++++ lib/promote_release.js | 45 ++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index d6decff7..a0a71fa3 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -14,6 +14,10 @@ const PROMOTE = 'promote'; const RELEASERS = 'releasers'; const releaseOptions = { + dryRun: { + describe: 'Skip all the steps that involve touching more than the local clone', + type: 'boolean' + }, prepare: { describe: 'Prepare a new release of Node.js', type: 'boolean' diff --git a/lib/promote_release.js b/lib/promote_release.js index b6e49d64..549d98b2 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -13,6 +13,7 @@ import Session from './session.js'; export default class ReleasePromotion extends Session { constructor(argv, cli, dir) { super(cli, dir, argv.prid); + this.dryRun = argv.dryRun; this.isLTS = false; this.ltsCodename = ''; this.date = ''; @@ -165,6 +166,17 @@ export default class ReleasePromotion extends Session { throw new Error('Aborted'); } } + const shouldPushDefaultBranch = await cli.prompt('Push to main?', + { defaultAnswer: true }); + if (!shouldPushDefaultBranch) { + cli.warn(`Aborting release promotion for version ${version}`); + throw new Error('Aborted'); + } + if (this.dryRun) { + cli.info('Skipping pushing to main in dry-run mode'); + } else { + await forceRunAsync('git', ['push', this.upstream, 'HEAD:main'], { ignoreFailure: false }); + } // Push release tag. const shouldPushTag = await cli.prompt('Push release tag?', @@ -235,7 +247,7 @@ export default class ReleasePromotion extends Session { const systemDate = new Date().toISOString().slice(0, 10); if (this.date !== systemDate) { cli.warn( - `The release date (${this.date}) does not match the system date for today (${systemDate}).` + `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'); @@ -282,17 +294,21 @@ export default class ReleasePromotion extends Session { ignoreFailure: false }); - Object.assign( - this, - await this.validateReleaseCommit(releaseCommitMessage) - ); + 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}`, '--', - `doc/changelogs/CHANGELOG_V${this.version.major}.md` + `doc/changelogs/CHANGELOG_V${this.versionComponents.major}.md` ], { captureStdout: true, ignoreFailure: false }); const headingLine = /^\+## \d{4}-\d{2}-\d{2}, Version \d.+$/m.exec(changeLogDiff); if (headingLine == null) { @@ -365,7 +381,11 @@ export default class ReleasePromotion extends Session { } mergeProposalBranch() { - const { stagingBranch, versionComponents } = this; + const { cli, dryRun, stagingBranch, versionComponents } = this; + if (dryRun) { + cli.info('Skipping merging the proposal branch in dry-run mode'); + return; + } const releaseBranch = `v${versionComponents.major}.x`; @@ -378,13 +398,22 @@ export default class ReleasePromotion extends Session { } pushReleaseTag() { - const { version } = this; + const { cli, dryRun, version } = this; + if (dryRun) { + cli.info('Skipping pushing the tag in dry-run mode'); + return; + } const tagVersion = `v${version}`; return forceRunAsync('git', ['push', this.upstream, tagVersion], { ignoreFailure: false }); } async promoteAndSignRelease(keyPath) { + const { cli, dryRun } = this; + if (dryRun) { + cli.info('Skipping promoting the release in dry-run mode'); + return; + } await forceRunAsync('./tools/release.sh', ['-i', keyPath], { ignoreFailure: false }); } From 70294cf9f3b0e2e94c1913f78ed9a0666f5b6235 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:29:22 +0200 Subject: [PATCH 25/47] fix conflicts making the process crash --- lib/promote_release.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 549d98b2..9eb34aec 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -423,7 +423,7 @@ export default class ReleasePromotion extends Session { await this.tryResetBranch(); - // There will be conflicts. - await forceRunAsync('git', ['cherry-pick', releaseCommitSha], { ignoreFailure: false }); + // There will be conflicts, we do not want to treat this as a failure. + await forceRunAsync('git', ['cherry-pick', releaseCommitSha], { ignoreFailure: true }); } } From 549ac8e8b51690517f3d24f5b558c9914080502b Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:38:46 +0200 Subject: [PATCH 26/47] fix bug when checking modified files --- lib/promote_release.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 9eb34aec..3bec59c9 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -157,15 +157,17 @@ export default class ReleasePromotion extends Session { const releaseCommitOnDefaultBranch = await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'], { captureStdout: true, ignoreFailure: false }); - const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.split('\n'); + 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}`)('\n')}`); + 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 default branch const shouldPushDefaultBranch = await cli.prompt('Push to main?', { defaultAnswer: true }); if (!shouldPushDefaultBranch) { From 4bd64c9457df360d798d36d20c71288822253bee Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:43:27 +0200 Subject: [PATCH 27/47] do not hard code default branch name --- lib/promote_release.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 3bec59c9..7040345f 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -168,16 +168,16 @@ export default class ReleasePromotion extends Session { } // Push to the remote default branch - const shouldPushDefaultBranch = await cli.prompt('Push to main?', + const shouldPushDefaultBranch = await cli.prompt(`Push to ${this.branch}?`, { defaultAnswer: true }); if (!shouldPushDefaultBranch) { cli.warn(`Aborting release promotion for version ${version}`); throw new Error('Aborted'); } if (this.dryRun) { - cli.info('Skipping pushing to main in dry-run mode'); + cli.info(`Skipping pushing to ${this.branch} in dry-run mode`); } else { - await forceRunAsync('git', ['push', this.upstream, 'HEAD:main'], { ignoreFailure: false }); + await forceRunAsync('git', ['push', this.upstream, this.branch], { ignoreFailure: false }); } // Push release tag. @@ -392,9 +392,9 @@ export default class ReleasePromotion extends Session { const releaseBranch = `v${versionComponents.major}.x`; return Promise.all([ - forceRunAsync('git', ['push', this.upstream, `HEAD:${releaseBranch}`], + forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${releaseBranch}`], { ignoreFailure: false }), - forceRunAsync('git', ['push', this.upstream, `HEAD:${stagingBranch}`, + forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${stagingBranch}`, { ignoreFailure: false }]) ]); } @@ -421,7 +421,7 @@ export default class ReleasePromotion extends Session { async cherryPickToDefaultBranch() { const releaseCommitSha = this.releaseCommitSha; - await forceRunAsync('git', ['checkout', 'main'], { ignoreFailure: false }); + await forceRunAsync('git', ['checkout', this.branch], { ignoreFailure: false }); await this.tryResetBranch(); From 1104f09b9dadf16c6f6d147a07aab0f198814cbb Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:49:06 +0200 Subject: [PATCH 28/47] fix type --- lib/promote_release.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 7040345f..1f6e263a 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -391,11 +391,12 @@ export default class ReleasePromotion extends Session { const releaseBranch = `v${versionComponents.major}.x`; + // TODO: find a solution for key passphrase from the terminal return Promise.all([ forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${releaseBranch}`], { ignoreFailure: false }), - forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${stagingBranch}`, - { ignoreFailure: false }]) + forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${stagingBranch}`], + { ignoreFailure: false }) ]); } From 5b918995eca230171251c51f6d575b3a8657ef62 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 14:56:14 +0200 Subject: [PATCH 29/47] add `--gpgSign`/`-S` option --- lib/promote_release.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 1f6e263a..49233845 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -17,6 +17,9 @@ export default class ReleasePromotion extends Session { this.isLTS = false; this.ltsCodename = ''; this.date = ''; + this.gpgSign = argv?.['gpg-sign'] + ? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']]) + : []; } async promote() { @@ -375,6 +378,7 @@ export default class ReleasePromotion extends Session { await forceRunAsync('git', ['add', filePath], { ignoreFailure: false }); return forceRunAsync('git', [ 'commit', + ...this.gpgSign, '-m', `Working on ${workingOnVersion}`, '-m', @@ -427,6 +431,7 @@ export default class ReleasePromotion extends Session { await this.tryResetBranch(); // There will be conflicts, we do not want to treat this as a failure. - await forceRunAsync('git', ['cherry-pick', releaseCommitSha], { ignoreFailure: true }); + await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, releaseCommitSha], + { ignoreFailure: true }); } } From 575196de87a515b77f2b51f3f808a941561fe51d Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 15:35:46 +0200 Subject: [PATCH 30/47] fixup! add `--gpgSign`/`-S` option --- components/git/release.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/git/release.js b/components/git/release.js index a0a71fa3..b06a3976 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -26,6 +26,10 @@ const releaseOptions = { describe: 'Promote new release of Node.js', type: 'boolean' }, + 'gpg-sign': { + describe: 'GPG-sign commits, will be passed to the git process', + alias: 'S' + }, security: { describe: 'Demarcate the new security release as a security release', type: 'boolean' From 7ed9d064dbe2b0ab559e9d78bebf3013118c0798 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 15:36:05 +0200 Subject: [PATCH 31/47] better handling of releaser not on the team --- components/git/release.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index b06a3976..708efeec 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -134,11 +134,14 @@ async function main(state, argv, cli, dir) { '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; aborting release`); - return; + cli.stopSpinner(); + cli.error(`${release.username} is not a Releaser`); + if (!argv.dryRun) { + throw new Error('aborted'); + } + } else { + cli.stopSpinner(`${release.username} is a Releaser`); } - cli.stopSpinner('Verified Releaser status'); return release.promote(); } From 66fdab573af0456ae0f487597dee0f9a0d9f0aa2 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 16:41:16 +0200 Subject: [PATCH 32/47] add `gh release create` note --- lib/promote_release.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index 49233845..edecf844 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -216,6 +216,18 @@ export default class ReleasePromotion extends Session { ' 3. Create the release on GitHub.\n' + ' 4. Optionally, announce the release 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 release:'); + cli.separator(); + cli.info( + 'awk \'' + + `/^## ${this.date.replaceAll('.', '\\.')}, 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 ${this.version} --verify-tag --latest=${ + !this.isLTS} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`); } async verifyPRAttributes() { @@ -320,6 +332,7 @@ export default class ReleasePromotion extends Session { cli.error('Cannot find section for the new release in CHANGELOG'); throw new Error('Aborted'); } + this.releaseTitle = headingLine[0].slice(4); const expectedLine = `+## ${releaseCommitMessage}, @${this.username}`; if (headingLine[0] !== expectedLine && !headingLine[0].startsWith(`${expectedLine} prepared by @`)) { From 79d37892e65adb2c8452d5ea568aac3154e82ab6 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 16:43:47 +0200 Subject: [PATCH 33/47] Fix `undefined` in spinner text --- components/git/release.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index 708efeec..aa4466b0 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -134,8 +134,7 @@ async function main(state, argv, cli, dir) { 'Username was undefined - do you have your .ncurc set up correctly?'); return; } else if (releasers.every(r => r.login !== release.username)) { - cli.stopSpinner(); - cli.error(`${release.username} is not a Releaser`); + cli.stopSpinner(`${release.username} is not a Releaser`, 'failed'); if (!argv.dryRun) { throw new Error('aborted'); } From d65fd6ace3d001ee0957b4c750f72a204c1c8e5a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Mon, 22 Jul 2024 16:49:06 +0200 Subject: [PATCH 34/47] fixup! add `gh release create` note --- lib/promote_release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index edecf844..0059654d 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -222,7 +222,7 @@ export default class ReleasePromotion extends Session { cli.separator(); cli.info( 'awk \'' + - `/^## ${this.date.replaceAll('.', '\\.')}, Version ${this.version.replaceAll('.', '\\.')}/,` + + `/^## ${this.date}, Version ${this.version.replaceAll('.', '\\.')} /,` + '/^<\\x2fa>$/{' + 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' + `}' doc/changelogs/CHANGELOG_V${ From 7c1dfbe417ee5953081962a69bdd0954e6d10573 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 23 Jul 2024 09:30:43 +0200 Subject: [PATCH 35/47] make `--dryRun` more useful --- components/git/release.js | 3 +- lib/promote_release.js | 125 ++++++++++++++++++++------------------ 2 files changed, 69 insertions(+), 59 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index aa4466b0..b154faba 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -15,7 +15,8 @@ const RELEASERS = 'releasers'; const releaseOptions = { dryRun: { - describe: 'Skip all the steps that involve touching more than the local clone', + describe: 'Do not run steps that involve touching more than the local clone, ' + + 'instead print the commands so the user can choose to run them manually', type: 'boolean' }, prepare: { diff --git a/lib/promote_release.js b/lib/promote_release.js index 0059654d..23e6e21f 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -118,17 +118,8 @@ export default class ReleasePromotion extends Session { await this.setupForNextRelease(); cli.stopSpinner('Successfully set up for next release'); - const shouldMergeProposalBranch = await cli.prompt( - 'Merge proposal branch into staging branch?'); - if (!shouldMergeProposalBranch) { - cli.warn(`Aborting release promotion for version ${version}`); - throw new Error('Aborted'); - } - // Merge vX.Y.Z-proposal into vX.x. - cli.startSpinner('Merging proposal branch'); await this.mergeProposalBranch(); - cli.stopSpinner('Merged proposal branch'); // Cherry pick release commit to master. const shouldCherryPick = await cli.prompt( @@ -147,7 +138,7 @@ export default class ReleasePromotion extends Session { // need to resolve, so confirm they've been resolved before // proceeding with next steps. cli.separator(); - cli.info('Resovle the conflicts and commit the result'); + cli.info('Resolve the conflicts and commit the result'); cli.separator(); const didResolveConflicts = await cli.prompt( 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true }); @@ -170,42 +161,11 @@ export default class ReleasePromotion extends Session { } } - // Push to the remote default branch - const shouldPushDefaultBranch = await cli.prompt(`Push to ${this.branch}?`, - { defaultAnswer: true }); - if (!shouldPushDefaultBranch) { - cli.warn(`Aborting release promotion for version ${version}`); - throw new Error('Aborted'); - } - if (this.dryRun) { - cli.info(`Skipping pushing to ${this.branch} in dry-run mode`); - } else { - await forceRunAsync('git', ['push', this.upstream, this.branch], { ignoreFailure: false }); - } - - // Push release tag. - const shouldPushTag = await cli.prompt('Push release tag?', - { defaultAnswer: true }); - if (!shouldPushTag) { - cli.warn(`Aborting release promotion for version ${version}`); - throw new Error('Aborted'); - } - await this.pushReleaseTag(); + // Push to the remote default branch and release tag. + await this.pushTagAndDefaultBranchToRemote(); // Promote and sign the release builds. - const shouldPromote = await cli.prompt('Promote and sign release builds?', - { defaultAnswer: true }); - if (!shouldPromote) { - cli.warn(`Aborting release promotion for version ${version}`); - throw new Error('Aborted'); - } - - // 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 }); - await this.promoteAndSignRelease(keyPath); + await this.promoteAndSignRelease(); cli.separator(); cli.ok(`Release promotion for ${version} complete.\n`); @@ -218,7 +178,7 @@ export default class ReleasePromotion extends Session { ' 5. Tag @nodejs-social-team on #nodejs-release Slack channel.\n'); cli.separator(); - cli.info('Use the following command to create the release:'); + cli.info('Use the following command to create the GitHub release:'); cli.separator(); cli.info( 'awk \'' + @@ -226,8 +186,8 @@ export default class ReleasePromotion extends Session { '/^<\\x2fa>$/{' + 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' + `}' doc/changelogs/CHANGELOG_V${ - this.versionComponents.major}.md | gh release create ${this.version} --verify-tag --latest=${ - !this.isLTS} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`); + this.versionComponents.major}.md | gh release create ${this.version} --verify-tag --latest${ + this.isLTS ? '=false' : ''} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`); } async verifyPRAttributes() { @@ -399,42 +359,91 @@ export default class ReleasePromotion extends Session { ], { ignoreFailure: false }); } - mergeProposalBranch() { + async mergeProposalBranch() { const { cli, dryRun, stagingBranch, versionComponents } = this; + const releaseBranch = `v${versionComponents.major}.x`; + + let prompt = 'Merge proposal branch into staging branch?'; if (dryRun) { - cli.info('Skipping merging the proposal branch in dry-run mode'); - return; + cli.info('Run the following commands to merge the staging branch:'); + cli.info(`git push ${this.upstream} HEAD:refs/heads/${releaseBranch}`); + cli.info(`git push ${this.upstream} HEAD:refs/heads/${stagingBranch}`); + prompt = 'Ready to continue?'; } - const releaseBranch = `v${versionComponents.major}.x`; + const shouldMergeProposalBranch = await cli.prompt(prompt, { defaultAnswer: true }); + if (!shouldMergeProposalBranch) { + cli.warn('Aborting release promotion'); + throw new Error('Aborted'); + } else if (dryRun) { + return; + } // TODO: find a solution for key passphrase from the terminal - return Promise.all([ + cli.startSpinner('Merging proposal branch'); + await Promise.all([ forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${releaseBranch}`], { ignoreFailure: false }), forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${stagingBranch}`], { ignoreFailure: false }) ]); + cli.stopSpinner('Merged proposal branch'); } - pushReleaseTag() { + async pushTagAndDefaultBranchToRemote() { const { cli, dryRun, version } = this; + const tagVersion = `v${version}`; + + let prompt = `Push release tag and ${this.branch} to ${this.upstream}?`; if (dryRun) { - cli.info('Skipping pushing the tag in dry-run mode'); + cli.info('Run the following commands to push to remote:'); + cli.info(`git push ${this.upstream} ${this.branch}`); + cli.info(`git push ${this.upstream} ${tagVersion}`); + 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; } - const tagVersion = `v${version}`; - return forceRunAsync('git', ['push', this.upstream, tagVersion], { ignoreFailure: false }); + cli.startSpinner('Pushing to remote'); + await Promise.all([ + forceRunAsync('git', ['push', this.upstream, this.branch], { ignoreFailure: false }), + forceRunAsync('git', ['push', this.upstream, tagVersion], { ignoreFailure: false }) + ]); + cli.stopSpinner(`Pushed ${tagVersion} and ${this.branch} to remote`); } - async promoteAndSignRelease(keyPath) { + async promoteAndSignRelease() { const { cli, dryRun } = this; + let prompt = 'Promote and sign release builds?'; + if (dryRun) { - cli.info('Skipping promoting the release in dry-run mode'); + cli.info('Run the following command to sign and promote the release:'); + cli.info('./tools/release.sh -i '); + 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 cherryPickToDefaultBranch() { From d9d97cbc257a8fe109c3361a7ee28793ba179cae Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 6 Aug 2024 20:05:59 +0200 Subject: [PATCH 36/47] fix "release date does not match the system date for today" incorrect warning --- lib/promote_release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 23e6e21f..a6c43129 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -222,7 +222,7 @@ export default class ReleasePromotion extends Session { } data.date = releaseCommitMessage.slice(0, 10); const systemDate = new Date().toISOString().slice(0, 10); - if (this.date !== systemDate) { + if (data.date !== systemDate) { cli.warn( `The release date (${data.date}) does not match the system date for today (${systemDate}).` ); From 8aa8c08c7a3556d8d8bbf5aa6a1542dd5c7fc86f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 6 Aug 2024 20:55:51 +0200 Subject: [PATCH 37/47] run `cherry-pick --continue` --- lib/promote_release.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index a6c43129..707aa937 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -9,6 +9,7 @@ import PRData from './pr_data.js'; import PRChecker from './pr_checker.js'; import Request from './request.js'; import Session from './session.js'; +import { existsSync } from 'node:fs'; export default class ReleasePromotion extends Session { constructor(argv, cli, dir) { @@ -147,6 +148,12 @@ export default class ReleasePromotion extends Session { 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'], From c1aa8cb5837ce1b3f67367086aa132b63dc2e17f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 14 Aug 2024 15:55:51 +0200 Subject: [PATCH 38/47] use one `git push` command instead of two --- lib/promote_release.js | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 707aa937..2b8fd5f3 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -373,8 +373,8 @@ export default class ReleasePromotion extends Session { let prompt = 'Merge proposal branch into staging branch?'; if (dryRun) { cli.info('Run the following commands to merge the staging branch:'); - cli.info(`git push ${this.upstream} HEAD:refs/heads/${releaseBranch}`); - cli.info(`git push ${this.upstream} HEAD:refs/heads/${stagingBranch}`); + cli.info(`git push ${this.upstream} HEAD:refs/heads/${releaseBranch + } HEAD:refs/heads/${stagingBranch}`); prompt = 'Ready to continue?'; } @@ -388,12 +388,9 @@ export default class ReleasePromotion extends Session { // TODO: find a solution for key passphrase from the terminal cli.startSpinner('Merging proposal branch'); - await Promise.all([ - forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${releaseBranch}`], - { ignoreFailure: false }), - forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${stagingBranch}`], - { ignoreFailure: false }) - ]); + await forceRunAsync('git', ['push', this.upstream, `HEAD:refs/heads/${releaseBranch}`, + `HEAD:refs/heads/${stagingBranch}`], + { ignoreFailure: false }); cli.stopSpinner('Merged proposal branch'); } @@ -404,8 +401,7 @@ export default class ReleasePromotion extends Session { let prompt = `Push release tag and ${this.branch} to ${this.upstream}?`; if (dryRun) { cli.info('Run the following commands to push to remote:'); - cli.info(`git push ${this.upstream} ${this.branch}`); - cli.info(`git push ${this.upstream} ${tagVersion}`); + cli.info(`git push ${this.upstream} ${this.branch} ${tagVersion}`); prompt = 'Ready to continue?'; } @@ -418,10 +414,8 @@ export default class ReleasePromotion extends Session { } cli.startSpinner('Pushing to remote'); - await Promise.all([ - forceRunAsync('git', ['push', this.upstream, this.branch], { ignoreFailure: false }), - forceRunAsync('git', ['push', this.upstream, tagVersion], { ignoreFailure: false }) - ]); + await forceRunAsync('git', ['push', this.upstream, this.branch, tagVersion], + { ignoreFailure: false }); cli.stopSpinner(`Pushed ${tagVersion} and ${this.branch} to remote`); } From 4e45c8bc115289d8b234b51ede7fd308653b9306 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 14 Aug 2024 19:36:26 +0200 Subject: [PATCH 39/47] Add a more verbose `--dry-run` message --- lib/promote_release.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index 2b8fd5f3..9a0aac2c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -11,6 +11,12 @@ import Request from './request.js'; import Session from './session.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, cli, dir) { super(cli, dir, argv.prid); @@ -372,6 +378,7 @@ export default class ReleasePromotion extends Session { let prompt = 'Merge proposal branch into staging branch?'; if (dryRun) { + cli.info(dryRunMessage); cli.info('Run the following commands to merge the staging branch:'); cli.info(`git push ${this.upstream} HEAD:refs/heads/${releaseBranch } HEAD:refs/heads/${stagingBranch}`); @@ -400,6 +407,7 @@ export default class ReleasePromotion extends Session { let prompt = `Push release tag and ${this.branch} to ${this.upstream}?`; if (dryRun) { + cli.info(dryRunMessage); cli.info('Run the following commands to push to remote:'); cli.info(`git push ${this.upstream} ${this.branch} ${tagVersion}`); prompt = 'Ready to continue?'; @@ -424,6 +432,7 @@ export default class ReleasePromotion extends Session { 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 '); prompt = 'Ready to continue?'; From 3cccce304701d298568b4ffffc65e714533a8672 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 14 Aug 2024 19:36:41 +0200 Subject: [PATCH 40/47] fix typo --- lib/promote_release.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 9a0aac2c..eca6433f 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -44,20 +44,20 @@ export default class ReleasePromotion extends Session { this.releaseCommitSha = releaseCommitSha; - let localCloseIsClean = true; + 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'); - localCloseIsClean = false; + localCloneIsClean = false; } try { await forceRunAsync('git', ['--no-pager', 'diff', '--exit-code'], { ignoreFailure: false }); } catch { cli.warn('Some local changes have not been committed'); - localCloseIsClean = false; + localCloneIsClean = false; } - if (!localCloseIsClean) { + if (!localCloneIsClean) { if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) { await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha], { ignoreFailure: false }); From 5ccb47472540e82d2da38fa79fd750ae9804b92d Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 14 Aug 2024 19:39:26 +0200 Subject: [PATCH 41/47] `--dry-run` -> `--run` --- components/git/release.js | 7 ++++--- lib/promote_release.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index b154faba..76811c06 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -14,9 +14,10 @@ const PROMOTE = 'promote'; const RELEASERS = 'releasers'; const releaseOptions = { - dryRun: { - describe: 'Do not run steps that involve touching more than the local clone, ' + - 'instead print the commands so the user can choose to run them manually', + 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' }, prepare: { diff --git a/lib/promote_release.js b/lib/promote_release.js index eca6433f..1bf0fa84 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -20,7 +20,7 @@ const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run export default class ReleasePromotion extends Session { constructor(argv, cli, dir) { super(cli, dir, argv.prid); - this.dryRun = argv.dryRun; + this.dryRun = !argv.run; this.isLTS = false; this.ltsCodename = ''; this.date = ''; From 0196904c509d6490e18e84e97347305df91a222f Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Thu, 22 Aug 2024 16:51:15 +0200 Subject: [PATCH 42/47] Fix `gh release create` command suggestion Co-authored-by: Rafael Gonzaga --- lib/promote_release.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index 1bf0fa84..e42a7a4c 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -199,7 +199,7 @@ export default class ReleasePromotion extends Session { '/^<\\x2fa>$/{' + 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' + `}' doc/changelogs/CHANGELOG_V${ - this.versionComponents.major}.md | gh release create ${this.version} --verify-tag --latest${ + this.versionComponents.major}.md | gh release create v${this.version} --verify-tag --latest${ this.isLTS ? '=false' : ''} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`); } From a9512d0707f1fbe62edccb59d5b4f383d354072a Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 11 Oct 2024 11:18:42 +0200 Subject: [PATCH 43/47] fetch default branch name from GitHub API --- lib/promote_release.js | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index e42a7a4c..ad4201a1 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -4,10 +4,8 @@ import semver from 'semver'; import * as gst from 'git-secure-tag'; import { forceRunAsync } from './run.js'; -import auth from './auth.js'; import PRData from './pr_data.js'; import PRChecker from './pr_checker.js'; -import Request from './request.js'; import Session from './session.js'; import { existsSync } from 'node:fs'; @@ -18,8 +16,9 @@ const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run '(might not work if you need to type a passphrase to push to the remote).'; export default class ReleasePromotion extends Session { - constructor(argv, cli, dir) { + constructor(argv, req, cli, dir) { super(cli, dir, argv.prid); + this.req = req; this.dryRun = !argv.run; this.isLTS = false; this.ltsCodename = ''; @@ -29,6 +28,17 @@ export default class ReleasePromotion extends Session { : []; } + get branch() { + return this.defaultBranch; + } + + async getDefaultBranch() { + const { repository: { defaultBranchRef } } = await this.req.gql( + 'DefaultBranchRef', + { owner: this.owner, repo: this.repo }); + return defaultBranchRef.name; + } + async promote() { const { prid, cli } = this; @@ -204,12 +214,9 @@ export default class ReleasePromotion extends Session { } async verifyPRAttributes() { - const { cli, prid, owner, repo } = this; - - const credentials = await auth({ github: true }); - const request = new Request(credentials); + const { cli, prid, owner, repo, req } = this; - const data = new PRData({ prid, owner, repo }, cli, request); + const data = new PRData({ prid, owner, repo }, cli, req); await data.getAll(); const checker = new PRChecker(cli, data, { prid, owner, repo }, { maxCommits: 0 }); @@ -405,11 +412,13 @@ export default class ReleasePromotion extends Session { const { cli, dryRun, version } = this; const tagVersion = `v${version}`; - let prompt = `Push release tag and ${this.branch} to ${this.upstream}?`; + this.defaultBranch ??= await this.getDefaultBranch(); + + let prompt = `Push release tag and ${this.defaultBranch} to ${this.upstream}?`; if (dryRun) { cli.info(dryRunMessage); cli.info('Run the following commands to push to remote:'); - cli.info(`git push ${this.upstream} ${this.branch} ${tagVersion}`); + cli.info(`git push ${this.upstream} ${this.defaultBranch} ${tagVersion}`); prompt = 'Ready to continue?'; } @@ -422,9 +431,9 @@ export default class ReleasePromotion extends Session { } cli.startSpinner('Pushing to remote'); - await forceRunAsync('git', ['push', this.upstream, this.branch, tagVersion], + await forceRunAsync('git', ['push', this.upstream, this.defaultBranch, tagVersion], { ignoreFailure: false }); - cli.stopSpinner(`Pushed ${tagVersion} and ${this.branch} to remote`); + cli.stopSpinner(`Pushed ${tagVersion} and ${this.defaultBranch} to remote`); } async promoteAndSignRelease() { @@ -457,8 +466,9 @@ export default class ReleasePromotion extends Session { } async cherryPickToDefaultBranch() { + this.defaultBranch ??= await this.getDefaultBranch(); const releaseCommitSha = this.releaseCommitSha; - await forceRunAsync('git', ['checkout', this.branch], { ignoreFailure: false }); + await forceRunAsync('git', ['checkout', this.defaultBranch], { ignoreFailure: false }); await this.tryResetBranch(); From 37ffe70cb708429833b67477d9f24a64d93bfbe1 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 11 Oct 2024 11:51:37 +0200 Subject: [PATCH 44/47] add spinner when fetching the proposal --- lib/promote_release.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/promote_release.js b/lib/promote_release.js index ad4201a1..0fe663da 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -69,9 +69,11 @@ 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], { ignoreFailure: false }); await forceRunAsync('git', ['reset', releaseCommitSha, '--hard'], { 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'); From c3fb51efb17689dc6ba0361390acd93c05428886 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 11 Oct 2024 11:54:47 +0200 Subject: [PATCH 45/47] fixup! fetch default branch name from GitHub API --- components/git/release.js | 6 +++--- lib/promote_release.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index 7a61dd80..8870c07e 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -124,11 +124,11 @@ async function main(state, argv, cli, dir) { return release.prepare(); } else if (state === PROMOTE) { - const release = new ReleasePromotion(argv, cli, dir); - - cli.startSpinner('Verifying Releaser status'); 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(); diff --git a/lib/promote_release.js b/lib/promote_release.js index 0fe663da..e9ceeb03 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -29,7 +29,7 @@ export default class ReleasePromotion extends Session { } get branch() { - return this.defaultBranch; + return this.defaultBranch ?? this.config.branch; } async getDefaultBranch() { From 55e4a798b21c837e8e517bd51496ae44767f7732 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Fri, 11 Oct 2024 12:45:55 +0200 Subject: [PATCH 46/47] allow reusing existing tag as long as it points to the correct commit --- lib/promote_release.js | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/lib/promote_release.js b/lib/promote_release.js index e9ceeb03..a83f088d 100644 --- a/lib/promote_release.js +++ b/lib/promote_release.js @@ -334,13 +334,27 @@ export default class ReleasePromotion extends Session { const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)'; - await new Promise((resolve, reject) => { - const api = new gst.API(process.cwd()); - api.sign(`v${version}`, releaseCommitSha, { - insecure: false, - m: `${this.date} Node.js v${version} ${releaseInfo} Release` - }, (err) => err ? reject(err) : resolve()); - }); + try { + await new Promise((resolve, reject) => { + const api = new gst.API(process.cwd()); + api.sign(`v${version}`, releaseCommitSha, { + insecure: false, + m: `${this.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 } + ); + } + await forceRunAsync('git', ['tag', '--verify', `v${version}`], { ignoreFailure: false }); + this.cli.info('Using the existing tag'); + } } // Set up the branch so that nightly builds are produced with the next From 94e38df1daae8e429fd889cf5a091268e9087bb6 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 6 Nov 2024 01:05:25 +0100 Subject: [PATCH 47/47] fix alphabetical order --- components/git/release.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/components/git/release.js b/components/git/release.js index 5ce08b82..51ea89a5 100644 --- a/components/git/release.js +++ b/components/git/release.js @@ -14,11 +14,17 @@ const PROMOTE = 'promote'; const RELEASERS = 'releasers'; const releaseOptions = { - 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' + filterLabel: { + describe: 'Labels separated by "," to filter security PRs', + type: 'string' + }, + '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', @@ -32,22 +38,16 @@ const releaseOptions = { describe: 'Default relase 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', type: 'boolean' }, - newVersion: { - describe: 'Version number of the release to be prepared', - type: 'string' - }, - filterLabel: { - describe: 'Labels separated by "," to filter security PRs', - type: 'string' - }, - 'gpg-sign': { - describe: 'GPG-sign commits, will be passed to the git process', - alias: 'S' - }, skipBranchDiff: { describe: 'Skips the initial branch-diff check when preparing releases', type: 'boolean'