Skip to content

Commit

Permalink
git-node: add release promotion step
Browse files Browse the repository at this point in the history
  • Loading branch information
codebytere committed Apr 9, 2020
1 parent 9d9fb2f commit 84b0a27
Show file tree
Hide file tree
Showing 3 changed files with 331 additions and 8 deletions.
42 changes: 34 additions & 8 deletions components/git/release.js
Expand Up @@ -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: {
Expand All @@ -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) {
Expand Down Expand Up @@ -59,23 +68,25 @@ 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,
handler
};

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) {
Expand All @@ -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();
}
}
296 changes: 296 additions & 0 deletions 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;
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -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",
Expand Down

0 comments on commit 84b0a27

Please sign in to comment.