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..eddf3177 --- /dev/null +++ b/lib/promote_release.js @@ -0,0 +1,296 @@ +'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('../lib/auth'); +const PRData = require('../lib/pr_data'); +const PRChecker = require('../lib/pr_checker'); +const Request = require('../../lib/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'; + } + + 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 00e7bc46..21fe041d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "figures": "^3.1.0", "fs-extra": "^8.1.0", "ghauth": "^4.0.0", + "git-secure-tag": "^2.3.1", "inquirer": "^7.0.0", "listr": "^0.14.3", "listr-input": "^0.2.0",