Skip to content

Commit 84b0a27

Browse files
committed
git-node: add release promotion step
1 parent 9d9fb2f commit 84b0a27

File tree

3 files changed

+331
-8
lines changed

3 files changed

+331
-8
lines changed

components/git/release.js

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
const yargs = require('yargs');
44

5+
const auth = require('../../lib/auth');
56
const CLI = require('../../lib/cli');
67
const ReleasePreparation = require('../../lib/prepare_release');
8+
const ReleasePromotion = require('../../lib/promote_release');
9+
const TeamInfo = require('../../lib/team_info');
10+
const Request = require('../../lib/request');
711
const { runPromise } = require('../../lib/run');
812

913
const PREPARE = 'prepare';
1014
const PROMOTE = 'promote';
15+
const RELEASERS = 'releasers';
1116

1217
const releaseOptions = {
1318
prepare: {
@@ -27,10 +32,14 @@ const releaseOptions = {
2732
function builder(yargs) {
2833
return yargs
2934
.options(releaseOptions).positional('newVersion', {
30-
describe: 'Version number of the release to be prepared or promoted'
35+
describe: 'Version number of the release to be prepared'
36+
}).positional('prid', {
37+
describe: 'PR number of the release to be promoted'
3138
})
3239
.example('git node release --prepare 1.2.3',
33-
'Prepare a new release of Node.js tagged v1.2.3');
40+
'Prepare a new release of Node.js tagged v1.2.3')
41+
.example('git node release --promote 12345',
42+
'Promote a prepared release of Node.js with PR #12345');
3443
}
3544

3645
function handler(argv) {
@@ -59,23 +68,25 @@ function release(state, argv) {
5968
}
6069

6170
module.exports = {
62-
command: 'release [newVersion|options]',
71+
command: 'release [newVersion|prid|options]',
6372
describe:
6473
'Manage an in-progress release or start a new one.',
6574
builder,
6675
handler
6776
};
6877

6978
async function main(state, argv, cli, dir) {
79+
let release;
80+
7081
if (state === PREPARE) {
71-
const prep = new ReleasePreparation(argv, cli, dir);
82+
release = new ReleasePreparation(argv, cli, dir);
7283

73-
if (prep.warnForWrongBranch()) return;
84+
if (release.warnForWrongBranch()) return;
7485

7586
// If the new version was automatically calculated, confirm it.
7687
if (!argv.newVersion) {
7788
const create = await cli.prompt(
78-
`Create release with new version ${prep.newVersion}?`,
89+
`Create release with new version ${release.newVersion}?`,
7990
{ defaultAnswer: true });
8091

8192
if (!create) {
@@ -84,8 +95,23 @@ async function main(state, argv, cli, dir) {
8495
}
8596
}
8697

87-
return prep.prepare();
98+
return release.prepare();
8899
} else if (state === PROMOTE) {
89-
// TODO(codebytere): implement release promotion.
100+
release = new ReleasePromotion(argv, cli, dir);
101+
102+
cli.startSpinner('Verifying Releaser status');
103+
const credentials = await auth({ github: true });
104+
const request = new Request(credentials);
105+
const info = new TeamInfo(cli, request, 'nodejs', RELEASERS);
106+
107+
const releasers = await info.getMembers();
108+
if (!releasers.some(r => r.login === release.username)) {
109+
cli.stopSpinner(
110+
`${release.username} is not a Releaser; aborting release`);
111+
return;
112+
}
113+
cli.stopSpinner('Verified Releaser status');
114+
115+
return release.promote();
90116
}
91117
}

lib/promote_release.js

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
'use strict';
2+
3+
const path = require('path');
4+
const { promises: fs } = require('fs');
5+
const semver = require('semver');
6+
7+
const { getMergedConfig } = require('./config');
8+
const { runSync } = require('./run');
9+
const auth = require('../lib/auth');
10+
const PRData = require('../lib/pr_data');
11+
const PRChecker = require('../lib/pr_checker');
12+
const Request = require('../../lib/request');
13+
14+
const isWindows = process.platform === 'win32';
15+
16+
class ReleasePromotion {
17+
constructor(argv, cli, dir) {
18+
this.cli = cli;
19+
this.dir = dir;
20+
this.isLTS = false;
21+
this.prid = argv.prid;
22+
this.ltsCodename = '';
23+
this.date = '';
24+
this.config = getMergedConfig(this.dir);
25+
}
26+
27+
async promote() {
28+
const { version, prid, cli } = this;
29+
30+
// In the promotion stage, we can pull most relevant data
31+
// from the release commit created in the preparation stage.
32+
await this.parseDataFromReleaseCommit();
33+
34+
// Verify that PR is ready to promote.
35+
cli.startSpinner('Verifying PR promotion readiness');
36+
const {
37+
jenkinsReady,
38+
githubCIReady,
39+
isApproved
40+
} = await this.verifyPRAttributes();
41+
if (!jenkinsReady) {
42+
cli.stopSpinner(`Jenkins CI is failing for #${prid}`);
43+
const proceed = await cli.prompt('Do you want to proceed?');
44+
if (!proceed) {
45+
cli.warn(`Aborting release promotion for version ${version}`);
46+
return;
47+
}
48+
} else if (!githubCIReady) {
49+
cli.stopSpinner(`GitHub CI is failing for #${prid}`);
50+
const proceed = await cli.prompt('Do you want to proceed?');
51+
if (!proceed) {
52+
cli.warn(`Aborting release promotion for version ${version}`);
53+
return;
54+
}
55+
} else if (!isApproved) {
56+
cli.stopSpinner(`#${prid} does not have sufficient approvals`);
57+
const proceed = await cli.prompt('Do you want to proceed?');
58+
if (!proceed) {
59+
cli.warn(`Aborting release promotion for version ${version}`);
60+
return;
61+
}
62+
}
63+
cli.stopSpinner(`The release PR for ${version} is ready to promote!`);
64+
65+
// Create and sign the release tag.
66+
const shouldTagAndSignRelease = await cli.prompt(
67+
'Tag and sign the release?');
68+
if (!shouldTagAndSignRelease) {
69+
cli.warn(`Aborting release promotion for version ${version}`);
70+
return;
71+
}
72+
this.secureTagRelease();
73+
74+
// Set up for next release.
75+
cli.startSpinner('Setting up for next release');
76+
await this.setupForNextRelease();
77+
cli.startSpinner('Successfully set up for next release');
78+
79+
const shouldMergeProposalBranch = await cli.prompt(
80+
'Merge proposal branch into staging branch?');
81+
if (!shouldMergeProposalBranch) {
82+
cli.warn(`Aborting release promotion for version ${version}`);
83+
return;
84+
}
85+
86+
// Merge vX.Y.Z-proposal into vX.x.
87+
cli.startSpinner('Merging proposal branch');
88+
await this.mergeProposalBranch();
89+
cli.startSpinner('Merged proposal branch');
90+
91+
// Cherry pick release commit to master.
92+
const shouldCherryPick = await cli.prompt(
93+
'Cherry-pick release commit to master?', { defaultAnswer: true });
94+
if (!shouldCherryPick) {
95+
cli.warn(`Aborting release promotion for version ${version}`);
96+
return;
97+
}
98+
await this.cherryPickToMaster();
99+
100+
// Push release tag.
101+
const shouldPushTag = await cli.prompt('Push release tag?',
102+
{ defaultAnswer: true });
103+
if (!shouldPushTag) {
104+
cli.warn(`Aborting release promotion for version ${version}`);
105+
return;
106+
}
107+
this.pushReleaseTag();
108+
109+
// Promote and sign the release builds.
110+
const shouldPromote = await cli.prompt('Promote and sign release builds?',
111+
{ defaultAnswer: true });
112+
if (!shouldPromote) {
113+
cli.warn(`Aborting release promotion for version ${version}`);
114+
return;
115+
}
116+
117+
const defaultKeyPath = '~/.ssh/node_id_rsa';
118+
const keyPath = await cli.prompt(
119+
`Please enter the path to your ssh key (Default ${defaultKeyPath}): `,
120+
{ questionType: 'input', defaultAnswer: defaultKeyPath });
121+
this.promoteAndSignRelease(keyPath);
122+
123+
cli.separator();
124+
cli.ok(`Release promotion for ${version} complete.\n`);
125+
cli.info(
126+
'To finish this release, you\'ll need to: \n' +
127+
` 1) Check the release at: https://nodejs.org/dist/v${version}\n` +
128+
' 2) Create the blog post for nodejs.org\n' +
129+
' 3) Create the release on GitHub\n' +
130+
'Finally, proceed to Twitter and announce the new release!');
131+
}
132+
133+
async verifyPRAttributes() {
134+
const { cli, prid, owner, repo } = this;
135+
136+
const credentials = await auth({ github: true });
137+
const request = new Request(credentials);
138+
139+
const data = new PRData({ prid, owner, repo }, cli, request);
140+
await data.getAll();
141+
142+
const checker = new PRChecker(cli, data, { prid, owner, repo });
143+
const jenkinsReady = checker.checkJenkinsCI();
144+
const githubCIReady = checker.checkGitHubCI();
145+
const isApproved = checker.checkReviewsAndWait(false /* checkComments */);
146+
147+
return {
148+
jenkinsReady,
149+
githubCIReady,
150+
isApproved
151+
};
152+
}
153+
154+
async parseDataFromReleaseCommit() {
155+
const { cli } = this;
156+
157+
const releaseCommitMessage = runSync(
158+
'git', ['log', '-n', '1', '--pretty=format:\'%s\'']).trim();
159+
160+
const components = releaseCommitMessage.split(' ');
161+
162+
// Parse out release date.
163+
if (!/\d{4}-\d{2}-\d{2}/.match(components[0])) {
164+
cli.error(`Release commit contains invalid date: ${components[0]}`);
165+
return;
166+
}
167+
this.date = components[0];
168+
169+
// Parse out release version.
170+
const version = semver.clean(components[2]);
171+
if (!semver.valid(version)) {
172+
cli.error(`Release commit contains invalid semantic version: ${version}`);
173+
return;
174+
}
175+
176+
this.version = version;
177+
this.stagingBranch = `v${semver.major(version)}.x-staging`;
178+
this.versionComponents = {
179+
major: semver.major(version),
180+
minor: semver.minor(version),
181+
patch: semver.patch(version)
182+
};
183+
184+
// Parse out LTS status and codename.
185+
if (components.length === 5) {
186+
this.isLTS = true;
187+
this.ltsCodename = components[3];
188+
}
189+
}
190+
191+
getCommitSha(position = 0) {
192+
return runSync('git', ['rev-parse', `HEAD~${position}`]);
193+
}
194+
195+
get owner() {
196+
return this.config.owner || 'nodejs';
197+
}
198+
199+
get repo() {
200+
return this.config.repo || 'node';
201+
}
202+
203+
secureTagRelease() {
204+
const { version, isLTS, ltsCodename } = this;
205+
206+
const secureTag = path.join(
207+
__dirname,
208+
'../node_modules/.bin/git-secure-tag' + (isWindows ? '.cmd' : '')
209+
);
210+
211+
const releaseInfo = isLTS ? `'${ltsCodename}' (LTS)` : '(Current)';
212+
const secureTagOptions = [
213+
`v${version}`,
214+
this.getCommitSha(),
215+
'-sm',
216+
`"${this.date} Node.js v${version} ${releaseInfo} Release"`
217+
];
218+
219+
return runSync(secureTag, secureTagOptions);
220+
}
221+
222+
// Set up the branch so that nightly builds are produced with the next
223+
// version number and a pre-release tag.
224+
async setupForNextRelease() {
225+
const { versionComponents, prid } = this;
226+
227+
// Update node_version.h for next patch release.
228+
const filePath = path.resolve('src', 'node_version.h');
229+
const data = await fs.readFile(filePath, 'utf8');
230+
const arr = data.split('\n');
231+
232+
const patchVersion = versionComponents.patch + 1;
233+
arr.forEach((line, idx) => {
234+
if (line.includes('#define NODE_PATCH_VERSION')) {
235+
arr[idx] = `#define NODE_PATCH_VERSION ${patchVersion}`;
236+
} else if (line.includes('#define NODE_VERSION_IS_RELEASE')) {
237+
arr[idx] = '#define NODE_VERSION_IS_RELEASE 0';
238+
}
239+
});
240+
241+
await fs.writeFile(filePath, arr.join('\n'));
242+
243+
const workingOnVersion =
244+
`${versionComponents.major}.${versionComponents.minor}.${patchVersion}`;
245+
246+
// Create 'Working On' commit.
247+
runSync('git', ['add', filePath]);
248+
return runSync('git', [
249+
'commit',
250+
'-m',
251+
`Working on ${workingOnVersion}`,
252+
'-m',
253+
`PR-URL: https://github.com/nodejs/node/pull/${prid}`
254+
]);
255+
}
256+
257+
async mergeProposalBranch() {
258+
const { stagingBranch, versionComponents, version } = this;
259+
260+
const releaseBranch = `v${versionComponents.major}.x`;
261+
const proposalBranch = `v${version}-proposal`;
262+
263+
runSync('git', ['checkout', releaseBranch]);
264+
runSync('git', ['merge', '--ff-only', proposalBranch]);
265+
runSync('git', ['push', 'upstream', releaseBranch]);
266+
runSync('git', ['checkout', stagingBranch]);
267+
runSync('git', ['rebase', releaseBranch]);
268+
runSync('git', ['push', 'upstream', stagingBranch]);
269+
}
270+
271+
pushReleaseTag() {
272+
const { version } = this;
273+
274+
const tagVersion = `v${version}`;
275+
return runSync('git', ['push', 'upstream', tagVersion]);
276+
}
277+
278+
promoteAndSignRelease(keyPath) {
279+
return runSync('./tools/release.sh', ['-i', keyPath]);
280+
}
281+
282+
async cherryPickToMaster() {
283+
// Since we've committed the Working On commit,
284+
// the release commit will be 1 removed from
285+
// tip-of-tree (e.g HEAD~1).
286+
const releaseCommitSha = this.getCommitSha(1);
287+
runSync('git', ['checkout', 'master']);
288+
289+
// There will be conflicts.
290+
runSync('git', ['cherry-pick', releaseCommitSha]);
291+
// TODO(codebytere): gracefully handle conflicts and
292+
// wait for the releaser to resolve.
293+
}
294+
}
295+
296+
module.exports = ReleasePromotion;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"figures": "^3.1.0",
4242
"fs-extra": "^8.1.0",
4343
"ghauth": "^4.0.0",
44+
"git-secure-tag": "^2.3.1",
4445
"inquirer": "^7.0.0",
4546
"listr": "^0.14.3",
4647
"listr-input": "^0.2.0",

0 commit comments

Comments
 (0)