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 06dfd34 commit b44d683
Show file tree
Hide file tree
Showing 2 changed files with 253 additions and 35 deletions.
10 changes: 6 additions & 4 deletions components/git/release.js
Expand Up @@ -32,12 +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')
.example('git node release --promote 1.2.3',
'Promote a prepared 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 @@ -66,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,
Expand Down
278 changes: 247 additions & 31 deletions lib/promote_release.js
Expand Up @@ -5,76 +5,292 @@ const { promises: fs } = require('fs');
const semver = require('semver');

const { getMergedConfig } = require('./config');
const { runAsync, runSync } = require('./run');
const { writeJson, readJson } = require('./file');
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 ReleasePreparation {
class ReleasePromotion {
constructor(argv, cli, dir) {
this.cli = cli;
this.dir = dir;
this.isSecurityRelease = argv.security;
this.isLTS = await this.checkIsLTS;
this.isLTS = false;
this.prid = argv.prid;
this.ltsCodename = '';
this.date = '';
this.config = getMergedConfig(this.dir);
}

async promote() {
// Verify that CI is green
const { version, prid, cli } = this;

// Verify that the PR has at least one approval from a Releaser
// 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!`);

async checkIsLTS() {
const filePath = path.resolve('src', 'node_version.h');
const data = await fs.readFile(filePath, 'utf8');
const arr = data.split('\n');
// 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();

for (let idx = 0; idx < arr.length; idx++) {
const line = arr[idx];
if (line.includes('#define NODE_VERSION_IS_LTS')) {
return line.split(' ')[2] === '1';
}
// 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!');
}

// Returns the LTS codename for the Release line; e.g 'Erbium' for 12.x.
async checkReleaseCodename() {
const filePath = path.resolve('src', 'node_version.h');
const data = await fs.readFile(filePath, 'utf8');
const arr = data.split('\n');
async verifyPRAttributes() {
const { cli, prid, owner, repo } = this;

for (let idx = 0; idx < arr.length; idx++) {
const line = arr[idx];
if (line.includes('#define NODE_VERSION_LTS_CODENAME')) {
return line.split(' ')[2];
}
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 } = this;
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 ${versionComponents.patch + 1}`;
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;

0 comments on commit b44d683

Please sign in to comment.