Skip to content

Commit b44d683

Browse files
committed
git-node: add release promotion step
1 parent 06dfd34 commit b44d683

File tree

2 files changed

+253
-35
lines changed

2 files changed

+253
-35
lines changed

components/git/release.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ const releaseOptions = {
3232
function builder(yargs) {
3333
return yargs
3434
.options(releaseOptions).positional('newVersion', {
35-
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'
3638
})
3739
.example('git node release --prepare 1.2.3',
3840
'Prepare a new release of Node.js tagged v1.2.3')
39-
.example('git node release --promote 1.2.3',
40-
'Promote a prepared 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');
4143
}
4244

4345
function handler(argv) {
@@ -66,7 +68,7 @@ function release(state, argv) {
6668
}
6769

6870
module.exports = {
69-
command: 'release [newVersion|options]',
71+
command: 'release [newVersion|prid|options]',
7072
describe:
7173
'Manage an in-progress release or start a new one.',
7274
builder,

lib/promote_release.js

Lines changed: 247 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,76 +5,292 @@ const { promises: fs } = require('fs');
55
const semver = require('semver');
66

77
const { getMergedConfig } = require('./config');
8-
const { runAsync, runSync } = require('./run');
9-
const { writeJson, readJson } = require('./file');
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');
1013

1114
const isWindows = process.platform === 'win32';
1215

13-
class ReleasePreparation {
16+
class ReleasePromotion {
1417
constructor(argv, cli, dir) {
1518
this.cli = cli;
1619
this.dir = dir;
17-
this.isSecurityRelease = argv.security;
18-
this.isLTS = await this.checkIsLTS;
20+
this.isLTS = false;
21+
this.prid = argv.prid;
1922
this.ltsCodename = '';
2023
this.date = '';
2124
this.config = getMergedConfig(this.dir);
2225
}
2326

2427
async promote() {
25-
// Verify that CI is green
28+
const { version, prid, cli } = this;
2629

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

29-
//
30-
}
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!`);
3164

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

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

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

51-
for (let idx = 0; idx < arr.length; idx++) {
52-
const line = arr[idx];
53-
if (line.includes('#define NODE_VERSION_LTS_CODENAME')) {
54-
return line.split(' ')[2];
55-
}
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];
56188
}
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+
];
57218

58-
219+
return runSync(secureTag, secureTagOptions);
59220
}
60221

61222
// Set up the branch so that nightly builds are produced with the next
62223
// version number and a pre-release tag.
63224
async setupForNextRelease() {
64-
const { versionComponents } = this;
225+
const { versionComponents, prid } = this;
65226

227+
// Update node_version.h for next patch release.
66228
const filePath = path.resolve('src', 'node_version.h');
67229
const data = await fs.readFile(filePath, 'utf8');
68230
const arr = data.split('\n');
69231

232+
const patchVersion = versionComponents.patch + 1;
70233
arr.forEach((line, idx) => {
71234
if (line.includes('#define NODE_PATCH_VERSION')) {
72-
arr[idx] = `#define NODE_PATCH_VERSION ${versionComponents.patch + 1}`;
235+
arr[idx] = `#define NODE_PATCH_VERSION ${patchVersion}`;
73236
} else if (line.includes('#define NODE_VERSION_IS_RELEASE')) {
74237
arr[idx] = '#define NODE_VERSION_IS_RELEASE 0';
75238
}
76239
});
77240

78241
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.
79293
}
80294
}
295+
296+
module.exports = ReleasePromotion;

0 commit comments

Comments
 (0)