@node-core/utils 5.6.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,31 @@
1
+ import auth from '../../lib/auth.js';
1
2
  import CLI from '../../lib/cli.js';
2
3
  import ReleasePreparation from '../../lib/prepare_release.js';
4
+ import ReleasePromotion from '../../lib/promote_release.js';
5
+ import TeamInfo from '../../lib/team_info.js';
6
+ import Request from '../../lib/request.js';
3
7
  import { runPromise } from '../../lib/run.js';
4
8
 
5
- export const command = 'release [newVersion|options]';
9
+ export const command = 'release [prid|options]';
6
10
  export const describe = 'Manage an in-progress release or start a new one.';
7
11
 
8
12
  const PREPARE = 'prepare';
9
13
  const PROMOTE = 'promote';
14
+ const RELEASERS = 'releasers';
10
15
 
11
16
  const releaseOptions = {
17
+ filterLabel: {
18
+ describe: 'Labels separated by "," to filter security PRs',
19
+ type: 'string'
20
+ },
21
+ 'gpg-sign': {
22
+ describe: 'GPG-sign commits, will be passed to the git process',
23
+ alias: 'S'
24
+ },
25
+ newVersion: {
26
+ describe: 'Version number of the release to be prepared',
27
+ type: 'string'
28
+ },
12
29
  prepare: {
13
30
  describe: 'Prepare a new release of Node.js',
14
31
  type: 'boolean'
@@ -21,14 +38,16 @@ const releaseOptions = {
21
38
  describe: 'Default relase date when --prepare is used. It must be YYYY-MM-DD',
22
39
  type: 'string'
23
40
  },
41
+ run: {
42
+ describe: 'Run steps that involve touching more than the local clone, ' +
43
+ 'including `git push` commands. Might not work if a passphrase ' +
44
+ 'required to push to the remote clone.',
45
+ type: 'boolean'
46
+ },
24
47
  security: {
25
48
  describe: 'Demarcate the new security release as a security release',
26
49
  type: 'boolean'
27
50
  },
28
- filterLabel: {
29
- describe: 'Labels separated by "," to filter security PRs',
30
- type: 'string'
31
- },
32
51
  skipBranchDiff: {
33
52
  describe: 'Skips the initial branch-diff check when preparing releases',
34
53
  type: 'boolean'
@@ -49,11 +68,16 @@ let yargsInstance;
49
68
  export function builder(yargs) {
50
69
  yargsInstance = yargs;
51
70
  return yargs
52
- .options(releaseOptions).positional('newVersion', {
53
- describe: 'Version number of the release to be prepared or promoted'
71
+ .options(releaseOptions).positional('prid', {
72
+ describe: 'PR number or URL of the release proposal to be promoted',
73
+ type: 'string'
54
74
  })
55
- .example('git node release --prepare 1.2.3',
56
- 'Prepare a release of Node.js tagged v1.2.3')
75
+ .example('git node release --prepare --security',
76
+ 'Prepare a new security release of Node.js with auto-determined version')
77
+ .example('git node release --prepare --newVersion=1.2.3',
78
+ 'Prepare a new release of Node.js tagged v1.2.3')
79
+ .example('git node release --promote 12345',
80
+ 'Promote a prepared release of Node.js with PR #12345')
57
81
  .example('git node --prepare --startLTS',
58
82
  'Prepare the first LTS release');
59
83
  }
@@ -88,17 +112,21 @@ function release(state, argv) {
88
112
  }
89
113
 
90
114
  async function main(state, argv, cli, dir) {
115
+ const prID = /^(?:https:\/\/github\.com\/nodejs\/node\/pull\/)?(\d+)$/.exec(argv.prid);
116
+ if (prID) {
117
+ argv.prid = Number(prID[1]);
118
+ }
91
119
  if (state === PREPARE) {
92
- const prep = new ReleasePreparation(argv, cli, dir);
120
+ const release = new ReleasePreparation(argv, cli, dir);
93
121
 
94
- await prep.prepareLocalBranch();
122
+ await release.prepareLocalBranch();
95
123
 
96
- if (prep.warnForWrongBranch()) return;
124
+ if (release.warnForWrongBranch()) return;
97
125
 
98
126
  // If the new version was automatically calculated, confirm it.
99
127
  if (!argv.newVersion) {
100
128
  const create = await cli.prompt(
101
- `Create release with new version ${prep.newVersion}?`,
129
+ `Create release with new version ${release.newVersion}?`,
102
130
  { defaultAnswer: true });
103
131
 
104
132
  if (!create) {
@@ -107,8 +135,30 @@ async function main(state, argv, cli, dir) {
107
135
  }
108
136
  }
109
137
 
110
- return prep.prepare();
138
+ return release.prepare();
111
139
  } else if (state === PROMOTE) {
112
- // TODO(codebytere): implement release promotion.
140
+ const credentials = await auth({ github: true });
141
+ const request = new Request(credentials);
142
+ const release = new ReleasePromotion(argv, request, cli, dir);
143
+
144
+ cli.startSpinner('Verifying Releaser status');
145
+ const info = new TeamInfo(cli, request, 'nodejs', RELEASERS);
146
+
147
+ const releasers = await info.getMembers();
148
+ if (release.username === undefined) {
149
+ cli.stopSpinner('Failed to verify Releaser status');
150
+ cli.info(
151
+ 'Username was undefined - do you have your .ncurc set up correctly?');
152
+ return;
153
+ } else if (releasers.every(r => r.login !== release.username)) {
154
+ cli.stopSpinner(`${release.username} is not a Releaser`, 'failed');
155
+ if (!argv.dryRun) {
156
+ throw new Error('aborted');
157
+ }
158
+ } else {
159
+ cli.stopSpinner(`${release.username} is a Releaser`);
160
+ }
161
+
162
+ return release.promote();
113
163
  }
114
164
  }
@@ -44,10 +44,22 @@ export function builder(yargs) {
44
44
  describe: 'Bump V8 embedder version number or patch version',
45
45
  default: true
46
46
  })
47
+ .option('gpg-sign', {
48
+ alias: 'S',
49
+ type: 'boolean',
50
+ describe: 'GPG-sign commits',
51
+ default: false
52
+ })
53
+ .option('preserve-original-author', {
54
+ type: 'boolean',
55
+ describe: 'Preserve original commit author and date',
56
+ default: true
57
+ })
47
58
  .option('squash', {
48
59
  type: 'boolean',
49
60
  describe:
50
- 'If multiple commits are backported, squash them into one',
61
+ 'If multiple commits are backported, squash them into one. When ' +
62
+ '`--squash` is passed, `--preserve-original-author` will be ignored',
51
63
  default: false
52
64
  });
53
65
  }
@@ -88,7 +100,7 @@ export function handler(argv) {
88
100
  input,
89
101
  spawnArgs: {
90
102
  cwd: options.nodeDir,
91
- stdio: input ? ['pipe', 'ignore', 'ignore'] : 'ignore'
103
+ stdio: input ? ['pipe', 'inherit', 'inherit'] : 'inherit'
92
104
  }
93
105
  });
94
106
  };
@@ -97,7 +109,7 @@ export function handler(argv) {
97
109
  return forceRunAsync('git', args, {
98
110
  ignoreFailure: false,
99
111
  captureStdout: true,
100
- spawnArgs: { cwd: options.v8Dir, stdio: ['ignore', 'pipe', 'ignore'] }
112
+ spawnArgs: { cwd: options.v8Dir, stdio: ['ignore', 'pipe', 'inherit'] }
101
113
  });
102
114
  };
103
115
 
@@ -52,7 +52,7 @@ async function main(argv) {
52
52
  if (fs.existsSync(statusFolder)) {
53
53
  const jsons = fs.readdirSync(statusFolder);
54
54
  supported = supported.concat(
55
- jsons.map(item => item.replace('.json', '')));
55
+ jsons.map(item => path.basename(item, path.extname(item))));
56
56
  } else {
57
57
  cli.warn(`Please create the status JSON files in ${statusFolder}`);
58
58
  }
@@ -167,7 +167,11 @@ export default class ReleasePreparation extends Session {
167
167
  return this.prepareSecurity();
168
168
  }
169
169
 
170
- if (this.runBranchDiff) {
170
+ const runBranchDiff = await cli.prompt(
171
+ 'Do you want to check if any additional commits could be backported ' +
172
+ '(recommended except for Maintenance releases)?',
173
+ { defaultAnswer: this.runBranchDiff });
174
+ if (runBranchDiff) {
171
175
  // TODO: UPDATE re-use
172
176
  // Check the branch diff to determine if the releaser
173
177
  // wants to backport any more commits before proceeding.
@@ -0,0 +1,493 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import semver from 'semver';
4
+ import * as gst from 'git-secure-tag';
5
+
6
+ import { forceRunAsync } from './run.js';
7
+ import PRData from './pr_data.js';
8
+ import PRChecker from './pr_checker.js';
9
+ import Session from './session.js';
10
+ import { existsSync } from 'node:fs';
11
+
12
+ const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run ' +
13
+ 'the `git push` commands, you would need to copy-paste the ' +
14
+ 'following command in another terminal window. Alternatively, ' +
15
+ 'pass `--run` flag to ask NCU to run the command for you ' +
16
+ '(might not work if you need to type a passphrase to push to the remote).';
17
+
18
+ export default class ReleasePromotion extends Session {
19
+ constructor(argv, req, cli, dir) {
20
+ super(cli, dir, argv.prid);
21
+ this.req = req;
22
+ this.dryRun = !argv.run;
23
+ this.isLTS = false;
24
+ this.ltsCodename = '';
25
+ this.date = '';
26
+ this.gpgSign = argv?.['gpg-sign']
27
+ ? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
28
+ : [];
29
+ }
30
+
31
+ get branch() {
32
+ return this.defaultBranch ?? this.config.branch;
33
+ }
34
+
35
+ async getDefaultBranch() {
36
+ const { repository: { defaultBranchRef } } = await this.req.gql(
37
+ 'DefaultBranchRef',
38
+ { owner: this.owner, repo: this.repo });
39
+ return defaultBranchRef.name;
40
+ }
41
+
42
+ async promote() {
43
+ const { prid, cli } = this;
44
+
45
+ // In the promotion stage, we can pull most relevant data
46
+ // from the release commit created in the preparation stage.
47
+ // Verify that PR is ready to promote.
48
+ const {
49
+ githubCIReady,
50
+ isApproved,
51
+ jenkinsReady,
52
+ releaseCommitSha
53
+ } = await this.verifyPRAttributes();
54
+
55
+ this.releaseCommitSha = releaseCommitSha;
56
+
57
+ let localCloneIsClean = true;
58
+ const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'],
59
+ { captureStdout: true, ignoreFailure: false });
60
+ if (currentHEAD.trim() !== releaseCommitSha) {
61
+ cli.warn('Current HEAD is not the release commit');
62
+ localCloneIsClean = false;
63
+ }
64
+ try {
65
+ await forceRunAsync('git', ['--no-pager', 'diff', '--exit-code'], { ignoreFailure: false });
66
+ } catch {
67
+ cli.warn('Some local changes have not been committed');
68
+ localCloneIsClean = false;
69
+ }
70
+ if (!localCloneIsClean) {
71
+ if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) {
72
+ cli.startSpinner('Fetching the proposal upstream...');
73
+ await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha],
74
+ { ignoreFailure: false });
75
+ await forceRunAsync('git', ['reset', releaseCommitSha, '--hard'], { ignoreFailure: false });
76
+ cli.stopSpinner('Local HEAD is now in sync with the proposal');
77
+ } else {
78
+ cli.error('Local clone is not ready');
79
+ throw new Error('Aborted');
80
+ }
81
+ }
82
+
83
+ await this.parseDataFromReleaseCommit();
84
+
85
+ const { version } = this;
86
+ cli.startSpinner('Verifying Jenkins CI status');
87
+ if (!jenkinsReady) {
88
+ cli.stopSpinner(
89
+ `Jenkins CI is failing for #${prid}`, cli.SPINNER_STATUS.FAILED);
90
+ const proceed = await cli.prompt('Do you want to proceed?');
91
+ if (!proceed) {
92
+ cli.warn(`Aborting release promotion for version ${version}`);
93
+ throw new Error('Aborted');
94
+ }
95
+ } else {
96
+ cli.stopSpinner('Jenkins CI is passing');
97
+ }
98
+
99
+ cli.startSpinner('Verifying GitHub CI status');
100
+ if (!githubCIReady) {
101
+ cli.stopSpinner(
102
+ `GitHub CI is failing for #${prid}`, cli.SPINNER_STATUS.FAILED);
103
+ const proceed = await cli.prompt('Do you want to proceed?');
104
+ if (!proceed) {
105
+ cli.warn(`Aborting release promotion for version ${version}`);
106
+ throw new Error('Aborted');
107
+ }
108
+ } else {
109
+ cli.stopSpinner('GitHub CI is passing');
110
+ }
111
+
112
+ cli.startSpinner('Verifying PR approval status');
113
+ if (!isApproved) {
114
+ cli.stopSpinner(
115
+ `#${prid} does not have sufficient approvals`,
116
+ cli.SPINNER_STATUS.FAILED);
117
+ const proceed = await cli.prompt('Do you want to proceed?');
118
+ if (!proceed) {
119
+ cli.warn(`Aborting release promotion for version ${version}`);
120
+ throw new Error('Aborted');
121
+ }
122
+ } else {
123
+ cli.stopSpinner(`#${prid} has necessary approvals`);
124
+ }
125
+
126
+ // Create and sign the release tag.
127
+ const shouldTagAndSignRelease = await cli.prompt(
128
+ 'Tag and sign the release?');
129
+ if (!shouldTagAndSignRelease) {
130
+ cli.warn(`Aborting release promotion for version ${version}`);
131
+ throw new Error('Aborted');
132
+ }
133
+ await this.secureTagRelease();
134
+
135
+ // Set up for next release.
136
+ cli.startSpinner('Setting up for next release');
137
+ const workingOnNewReleaseCommit = await this.setupForNextRelease();
138
+ cli.stopSpinner('Successfully set up for next release');
139
+
140
+ // Cherry pick release commit to master.
141
+ const shouldCherryPick = await cli.prompt(
142
+ 'Cherry-pick release commit to the default branch?', { defaultAnswer: true });
143
+ if (!shouldCherryPick) {
144
+ cli.warn(`Aborting release promotion for version ${version}`);
145
+ throw new Error('Aborted');
146
+ }
147
+ const appliedCleanly = await this.cherryPickToDefaultBranch();
148
+
149
+ // Ensure `node_version.h`'s `NODE_VERSION_IS_RELEASE` bit is not updated
150
+ await forceRunAsync('git', ['checkout',
151
+ appliedCleanly
152
+ ? 'HEAD^' // In the absence of conflict, the top of the remote branch is the commit before.
153
+ : 'HEAD', // In case of conflict, HEAD is still the top of the remove branch.
154
+ '--', 'src/node_version.h'],
155
+ { ignoreFailure: false });
156
+
157
+ if (appliedCleanly) {
158
+ // There were no conflicts, we have to amend the commit to revert the
159
+ // `node_version.h` changes.
160
+ await forceRunAsync('git', ['commit', ...this.gpgSign, '--amend', '--no-edit', '-n'],
161
+ { ignoreFailure: false });
162
+ } else {
163
+ // There will be remaining cherry-pick conflicts the Releaser will
164
+ // need to resolve, so confirm they've been resolved before
165
+ // proceeding with next steps.
166
+ cli.separator();
167
+ cli.info('Resolve the conflicts and commit the result');
168
+ cli.separator();
169
+ const didResolveConflicts = await cli.prompt(
170
+ 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
171
+ if (!didResolveConflicts) {
172
+ cli.warn(`Aborting release promotion for version ${version}`);
173
+ throw new Error('Aborted');
174
+ }
175
+ }
176
+
177
+ if (existsSync('.git/CHERRY_PICK_HEAD')) {
178
+ cli.info('Cherry-pick is still in progress, attempting to continue it.');
179
+ await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, '--continue'],
180
+ { ignoreFailure: false });
181
+ }
182
+
183
+ // Validate release commit on the default branch
184
+ const releaseCommitOnDefaultBranch =
185
+ await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
186
+ { captureStdout: true, ignoreFailure: false });
187
+ const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
188
+ await this.validateReleaseCommit(commitTitle);
189
+ if (modifiedFiles.some(file => !file.endsWith('.md'))) {
190
+ cli.warn('Some modified files are not markdown, that\'s unusual.');
191
+ cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
192
+ if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
193
+ throw new Error('Aborted');
194
+ }
195
+ }
196
+
197
+ // Push to the remote the release tag, and default, release, and staging branch.
198
+ await this.pushToRemote(workingOnNewReleaseCommit);
199
+
200
+ // Promote and sign the release builds.
201
+ await this.promoteAndSignRelease();
202
+
203
+ cli.separator();
204
+ cli.ok(`Release promotion for ${version} complete.\n`);
205
+ cli.info(
206
+ 'To finish this release, you\'ll need to: \n' +
207
+ ` 1. Check the release at: https://nodejs.org/dist/v${version}\n` +
208
+ ' 2. Create the blog post for nodejs.org.\n' +
209
+ ' 3. Create the release on GitHub.\n' +
210
+ ' 4. Optionally, announce the release on your social networks.\n' +
211
+ ' 5. Tag @nodejs-social-team on #nodejs-release Slack channel.\n');
212
+
213
+ cli.separator();
214
+ cli.info('Use the following command to create the GitHub release:');
215
+ cli.separator();
216
+ cli.info(
217
+ 'awk \'' +
218
+ `/^## ${this.date}, Version ${this.version.replaceAll('.', '\\.')} /,` +
219
+ '/^<a id="[0-9]+\\.[0-9]+\\.[0-9]+"><\\x2fa>$/{' +
220
+ 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' +
221
+ `}' doc/changelogs/CHANGELOG_V${
222
+ this.versionComponents.major}.md | gh release create v${this.version} --verify-tag --latest${
223
+ this.isLTS ? '=false' : ''} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`);
224
+ }
225
+
226
+ async verifyPRAttributes() {
227
+ const { cli, prid, owner, repo, req } = this;
228
+
229
+ const data = new PRData({ prid, owner, repo }, cli, req);
230
+ await data.getAll();
231
+
232
+ const checker = new PRChecker(cli, data, { prid, owner, repo }, { maxCommits: 0 });
233
+ const jenkinsReady = checker.checkJenkinsCI();
234
+ const githubCIReady = checker.checkGitHubCI();
235
+ const isApproved = checker.checkReviewsAndWait(new Date(), false);
236
+
237
+ return {
238
+ githubCIReady,
239
+ isApproved,
240
+ jenkinsReady,
241
+ releaseCommitSha: data.commits.at(-1).commit.oid
242
+ };
243
+ }
244
+
245
+ async validateReleaseCommit(releaseCommitMessage) {
246
+ const { cli } = this;
247
+ const data = {};
248
+ // Parse out release date.
249
+ if (!/^\d{4}-\d{2}-\d{2}, Version \d/.test(releaseCommitMessage)) {
250
+ cli.error(`Invalid Release commit message: ${releaseCommitMessage}`);
251
+ throw new Error('Aborted');
252
+ }
253
+ data.date = releaseCommitMessage.slice(0, 10);
254
+ const systemDate = new Date().toISOString().slice(0, 10);
255
+ if (data.date !== systemDate) {
256
+ cli.warn(
257
+ `The release date (${data.date}) does not match the system date for today (${systemDate}).`
258
+ );
259
+ if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
260
+ throw new Error('Aborted');
261
+ }
262
+ }
263
+
264
+ // Parse out release version.
265
+ data.version = releaseCommitMessage.slice(20, releaseCommitMessage.indexOf(' ', 20));
266
+ const version = semver.parse(data.version);
267
+ if (!version) {
268
+ cli.error(`Release commit contains invalid semantic version: ${data.version}`);
269
+ throw new Error('Aborted');
270
+ }
271
+
272
+ const { major, minor, patch } = version;
273
+ data.stagingBranch = `v${major}.x-staging`;
274
+ data.versionComponents = {
275
+ major,
276
+ minor,
277
+ patch
278
+ };
279
+
280
+ // Parse out LTS status and codename.
281
+ if (!releaseCommitMessage.endsWith(' (Current)')) {
282
+ const match = /'([^']+)' \(LTS\)$/.exec(releaseCommitMessage);
283
+ if (match == null) {
284
+ cli.error('Invalid release commit, it should match either Current or LTS release format');
285
+ throw new Error('Aborted');
286
+ }
287
+ data.isLTS = true;
288
+ data.ltsCodename = match[1];
289
+ }
290
+ return data;
291
+ }
292
+
293
+ async parseDataFromReleaseCommit() {
294
+ const { cli, releaseCommitSha } = this;
295
+
296
+ const releaseCommitMessage = await forceRunAsync('git', [
297
+ '--no-pager', 'log', '-1',
298
+ releaseCommitSha,
299
+ '--pretty=format:%s'], {
300
+ captureStdout: true,
301
+ ignoreFailure: false
302
+ });
303
+
304
+ const releaseCommitData = await this.validateReleaseCommit(releaseCommitMessage);
305
+
306
+ this.date = releaseCommitData.date;
307
+ this.version = releaseCommitData.version;
308
+ this.stagingBranch = releaseCommitData.stagingBranch;
309
+ this.versionComponents = releaseCommitData.versionComponents;
310
+ this.isLTS = releaseCommitData.isLTS;
311
+ this.ltsCodename = releaseCommitData.ltsCodename;
312
+
313
+ // Check if CHANGELOG show the correct releaser for the current release
314
+ const changeLogDiff = await forceRunAsync('git', [
315
+ '--no-pager', 'diff',
316
+ `${this.releaseCommitSha}^..${this.releaseCommitSha}`,
317
+ '--',
318
+ `doc/changelogs/CHANGELOG_V${this.versionComponents.major}.md`
319
+ ], { captureStdout: true, ignoreFailure: false });
320
+ const headingLine = /^\+## \d{4}-\d{2}-\d{2}, Version \d.+$/m.exec(changeLogDiff);
321
+ if (headingLine == null) {
322
+ cli.error('Cannot find section for the new release in CHANGELOG');
323
+ throw new Error('Aborted');
324
+ }
325
+ this.releaseTitle = headingLine[0].slice(4);
326
+ const expectedLine = `+## ${releaseCommitMessage}, @${this.username}`;
327
+ if (headingLine[0] !== expectedLine &&
328
+ !headingLine[0].startsWith(`${expectedLine} prepared by @`)) {
329
+ cli.error(
330
+ `Invalid section heading for CHANGELOG. Expected "${
331
+ expectedLine.slice(1)
332
+ }", found "${headingLine[0].slice(1)}`
333
+ );
334
+ if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
335
+ throw new Error('Aborted');
336
+ }
337
+ }
338
+ }
339
+
340
+ async secureTagRelease() {
341
+ const { version, isLTS, ltsCodename, releaseCommitSha } = this;
342
+
343
+ const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)';
344
+
345
+ try {
346
+ await new Promise((resolve, reject) => {
347
+ const api = new gst.API(process.cwd());
348
+ api.sign(`v${version}`, releaseCommitSha, {
349
+ insecure: false,
350
+ m: `${this.date} Node.js v${version} ${releaseInfo} Release`
351
+ }, (err) => err ? reject(err) : resolve());
352
+ });
353
+ } catch (err) {
354
+ const tagCommitSHA = await forceRunAsync('git', [
355
+ 'rev-parse', `refs/tags/v${version}^0`
356
+ ], { captureStdout: true, ignoreFailure: false });
357
+ if (tagCommitSHA.trim() !== releaseCommitSha) {
358
+ throw new Error(
359
+ `Existing version tag points to ${tagCommitSHA.trim()} instead of ${releaseCommitSha}`,
360
+ { cause: err }
361
+ );
362
+ }
363
+ await forceRunAsync('git', ['tag', '--verify', `v${version}`], { ignoreFailure: false });
364
+ this.cli.info('Using the existing tag');
365
+ }
366
+ }
367
+
368
+ // Set up the branch so that nightly builds are produced with the next
369
+ // version number and a pre-release tag.
370
+ async setupForNextRelease() {
371
+ const { versionComponents, prid } = this;
372
+
373
+ // Update node_version.h for next patch release.
374
+ const filePath = path.resolve('src', 'node_version.h');
375
+ const nodeVersionFile = await fs.open(filePath, 'r+');
376
+
377
+ const patchVersion = versionComponents.patch + 1;
378
+ let cursor = 0;
379
+ for await (const line of nodeVersionFile.readLines({ autoClose: false })) {
380
+ cursor += line.length + 1;
381
+ if (line === `#define NODE_PATCH_VERSION ${versionComponents.patch}`) {
382
+ await nodeVersionFile.write(`${patchVersion}`, cursor - 2, 'ascii');
383
+ } else if (line === '#define NODE_VERSION_IS_RELEASE 1') {
384
+ await nodeVersionFile.write('0', cursor - 2, 'ascii');
385
+ break;
386
+ }
387
+ }
388
+
389
+ await nodeVersionFile.close();
390
+
391
+ const workingOnVersion =
392
+ `v${versionComponents.major}.${versionComponents.minor}.${patchVersion}`;
393
+
394
+ // Create 'Working On' commit.
395
+ await forceRunAsync('git', ['add', filePath], { ignoreFailure: false });
396
+ await forceRunAsync('git', [
397
+ 'commit',
398
+ ...this.gpgSign,
399
+ '-m',
400
+ `Working on ${workingOnVersion}`,
401
+ '-m',
402
+ `PR-URL: https://github.com/nodejs/node/pull/${prid}`
403
+ ], { ignoreFailure: false });
404
+ const workingOnNewReleaseCommit = await forceRunAsync('git', ['rev-parse', 'HEAD'],
405
+ { ignoreFailure: false, captureStdout: true });
406
+ return workingOnNewReleaseCommit.trim();
407
+ }
408
+
409
+ async pushToRemote(workingOnNewReleaseCommit) {
410
+ const { cli, dryRun, version, versionComponents, stagingBranch } = this;
411
+ const releaseBranch = `v${versionComponents.major}.x`;
412
+ const tagVersion = `v${version}`;
413
+
414
+ this.defaultBranch ??= await this.getDefaultBranch();
415
+
416
+ let prompt = `Push release tag and commits to ${this.upstream}?`;
417
+ if (dryRun) {
418
+ cli.info(dryRunMessage);
419
+ cli.info('Run the following command to push to remote:');
420
+ cli.info(`git push ${this.upstream} ${
421
+ this.defaultBranch} ${
422
+ tagVersion} ${
423
+ workingOnNewReleaseCommit}:refs/heads/${releaseBranch} ${
424
+ workingOnNewReleaseCommit}:refs/heads/${stagingBranch}`);
425
+ cli.warn('Once pushed, you must not delete the local tag');
426
+ prompt = 'Ready to continue?';
427
+ }
428
+
429
+ const shouldPushTag = await cli.prompt(prompt, { defaultAnswer: true });
430
+ if (!shouldPushTag) {
431
+ cli.warn('Aborting release promotion');
432
+ throw new Error('Aborted');
433
+ } else if (dryRun) {
434
+ return;
435
+ }
436
+
437
+ cli.startSpinner('Pushing to remote');
438
+ await forceRunAsync('git', ['push', this.upstream, this.defaultBranch, tagVersion,
439
+ `${workingOnNewReleaseCommit}:refs/heads/${releaseBranch}`,
440
+ `${workingOnNewReleaseCommit}:refs/heads/${stagingBranch}`],
441
+ { ignoreFailure: false });
442
+ cli.stopSpinner(`Pushed ${tagVersion}, ${this.defaultBranch}, ${
443
+ releaseBranch}, and ${stagingBranch} to remote`);
444
+ cli.warn('Now that it has been pushed, you must not delete the local tag');
445
+ }
446
+
447
+ async promoteAndSignRelease() {
448
+ const { cli, dryRun } = this;
449
+ let prompt = 'Promote and sign release builds?';
450
+
451
+ if (dryRun) {
452
+ cli.info(dryRunMessage);
453
+ cli.info('Run the following command to sign and promote the release:');
454
+ cli.info('./tools/release.sh -i <keyPath>');
455
+ prompt = 'Ready to continue?';
456
+ }
457
+ const shouldPromote = await cli.prompt(prompt, { defaultAnswer: true });
458
+ if (!shouldPromote) {
459
+ cli.warn('Aborting release promotion');
460
+ throw new Error('Aborted');
461
+ } else if (dryRun) {
462
+ return;
463
+ }
464
+
465
+ // TODO: move this to .ncurc
466
+ const defaultKeyPath = '~/.ssh/node_id_rsa';
467
+ const keyPath = await cli.prompt(
468
+ `Please enter the path to your ssh key (Default ${defaultKeyPath}): `,
469
+ { questionType: 'input', defaultAnswer: defaultKeyPath });
470
+
471
+ cli.startSpinner('Signing and promoting the release');
472
+ await forceRunAsync('./tools/release.sh', ['-i', keyPath], { ignoreFailure: false });
473
+ cli.stopSpinner('Release has been signed and promoted');
474
+ }
475
+
476
+ async cherryPickToDefaultBranch() {
477
+ this.defaultBranch ??= await this.getDefaultBranch();
478
+ const releaseCommitSha = this.releaseCommitSha;
479
+ await forceRunAsync('git', ['checkout', this.defaultBranch], { ignoreFailure: false });
480
+
481
+ await this.tryResetBranch();
482
+
483
+ // There might be conflicts, we do not want to treat this as a hard failure,
484
+ // but we want to retain that information.
485
+ try {
486
+ await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, releaseCommitSha],
487
+ { ignoreFailure: false });
488
+ return true;
489
+ } catch {
490
+ return false;
491
+ }
492
+ }
493
+ }
package/lib/session.js CHANGED
@@ -87,6 +87,10 @@ export default class Session {
87
87
  return this.config.branch;
88
88
  }
89
89
 
90
+ get username() {
91
+ return this.config.username;
92
+ }
93
+
90
94
  get readme() {
91
95
  return this.config.readme;
92
96
  }
@@ -9,6 +9,7 @@ import { ListrEnquirerPromptAdapter } from '@listr2/prompt-adapter-enquirer';
9
9
  import { shortSha } from '../utils.js';
10
10
 
11
11
  import { getCurrentV8Version } from './common.js';
12
+ import { forceRunAsync } from '../run.js';
12
13
 
13
14
  export async function checkOptions(options) {
14
15
  if (options.sha.length > 1 && options.squash) {
@@ -41,6 +42,8 @@ export function doBackport(options) {
41
42
  }
42
43
  }
43
44
  todo.push(commitSquashedBackport());
45
+ } else if (options.preserveOriginalAuthor) {
46
+ todo.push(cherryPickV8Commits(options));
44
47
  } else {
45
48
  todo.push(applyAndCommitPatches());
46
49
  }
@@ -76,18 +79,47 @@ function commitSquashedBackport() {
76
79
  };
77
80
  };
78
81
 
79
- function commitPatch(patch) {
82
+ const commitTask = (patch, extraArgs, trailers) => async(ctx) => {
83
+ const messageTitle = formatMessageTitle([patch]);
84
+ const messageBody = formatMessageBody(patch, false, trailers);
85
+ await ctx.execGitNode('add', ['deps/v8']);
86
+ await ctx.execGitNode('commit', [
87
+ ...ctx.gpgSign, ...extraArgs,
88
+ '-m', messageTitle, '-m', messageBody
89
+ ]);
90
+ };
91
+
92
+ function amendHEAD(patch) {
80
93
  return {
81
- title: 'Commit patch',
94
+ title: 'Amend/commit',
82
95
  task: async(ctx) => {
83
- const messageTitle = formatMessageTitle([patch]);
84
- const messageBody = formatMessageBody(patch, false);
85
- await ctx.execGitNode('add', ['deps/v8']);
86
- await ctx.execGitNode('commit', ['-m', messageTitle, '-m', messageBody]);
96
+ let coAuthor;
97
+ if (patch.hadConflicts) {
98
+ const getGitConfigEntry = async(configKey) => {
99
+ const output = await forceRunAsync('git', ['config', configKey], {
100
+ ignoreFailure: false,
101
+ captureStdout: true,
102
+ spawnArgs: { cwd: ctx.nodeDir }
103
+ });
104
+ return output.trim();
105
+ };
106
+ await ctx.execGitNode('am', [...ctx.gpgSign, '--continue']);
107
+ coAuthor = `\nCo-authored-by: ${
108
+ await getGitConfigEntry('user.name')} <${
109
+ await getGitConfigEntry('user.email')}>`;
110
+ }
111
+ await commitTask(patch, ['--amend'], coAuthor)(ctx);
87
112
  }
88
113
  };
89
114
  }
90
115
 
116
+ function commitPatch(patch) {
117
+ return {
118
+ title: 'Commit patch',
119
+ task: commitTask(patch)
120
+ };
121
+ }
122
+
91
123
  function formatMessageTitle(patches) {
92
124
  const action =
93
125
  patches.some(patch => patch.hadConflicts) ? 'backport' : 'cherry-pick';
@@ -106,12 +138,12 @@ function formatMessageTitle(patches) {
106
138
  }
107
139
  }
108
140
 
109
- function formatMessageBody(patch, prefixTitle) {
141
+ function formatMessageBody(patch, prefixTitle, trailers = '') {
110
142
  const indentedMessage = patch.message.replace(/\n/g, '\n ');
111
143
  const body =
112
144
  'Original commit message:\n\n' +
113
145
  ` ${indentedMessage}\n\n` +
114
- `Refs: https://github.com/v8/v8/commit/${patch.sha}`;
146
+ `Refs: https://github.com/v8/v8/commit/${patch.sha}${trailers}`;
115
147
 
116
148
  if (prefixTitle) {
117
149
  const action = patch.hadConflicts ? 'Backport' : 'Cherry-pick';
@@ -167,6 +199,15 @@ function applyAndCommitPatches() {
167
199
  };
168
200
  }
169
201
 
202
+ function cherryPickV8Commits() {
203
+ return {
204
+ title: 'Cherry-pick commit from V8 clone to deps/v8',
205
+ task: (ctx, task) => {
206
+ return task.newListr(ctx.patches.map(cherryPickV8CommitTask));
207
+ }
208
+ };
209
+ }
210
+
170
211
  function applyPatchTask(patch) {
171
212
  return {
172
213
  title: `Commit ${shortSha(patch.sha)}`,
@@ -190,10 +231,33 @@ function applyPatchTask(patch) {
190
231
  };
191
232
  }
192
233
 
193
- async function applyPatch(ctx, task, patch) {
234
+ function cherryPickV8CommitTask(patch) {
235
+ return {
236
+ title: `Commit ${shortSha(patch.sha)}`,
237
+ task: (ctx, task) => {
238
+ const todo = [
239
+ {
240
+ title: 'Cherry-pick',
241
+ task: (ctx, task) => applyPatch(ctx, task, patch, 'am')
242
+ }
243
+ ];
244
+ if (ctx.bump !== false) {
245
+ if (ctx.nodeMajorVersion < 9) {
246
+ todo.push(incrementV8Version());
247
+ } else {
248
+ todo.push(incrementEmbedderVersion());
249
+ }
250
+ }
251
+ todo.push(amendHEAD(patch));
252
+ return task.newListr(todo);
253
+ }
254
+ };
255
+ }
256
+
257
+ async function applyPatch(ctx, task, patch, method = 'apply') {
194
258
  try {
195
259
  await ctx.execGitNode(
196
- 'apply',
260
+ method,
197
261
  ['-p1', '--3way', '--directory=deps/v8'],
198
262
  patch.data /* input */
199
263
  );
@@ -26,6 +26,7 @@ export function minor(options) {
26
26
  export async function backport(options) {
27
27
  const shouldStop = await checkOptions(options);
28
28
  if (shouldStop) return;
29
+ options.gpgSign = options.gpgSign ? ['-S'] : [];
29
30
  const tasks = new Listr(
30
31
  [updateV8Clone(), doBackport(options)],
31
32
  getOptions(options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node-core/utils",
3
- "version": "5.6.0",
3
+ "version": "5.7.0",
4
4
  "description": "Utilities for Node.js core collaborators",
5
5
  "type": "module",
6
6
  "engines": {
@@ -46,6 +46,7 @@
46
46
  "core-validate-commit": "^4.1.0",
47
47
  "figures": "^6.1.0",
48
48
  "ghauth": "^6.0.7",
49
+ "git-secure-tag": "^2.3.1",
49
50
  "js-yaml": "^4.1.0",
50
51
  "listr2": "^8.2.4",
51
52
  "lodash": "^4.17.21",