@node-core/utils 5.5.1 → 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'
@@ -17,14 +34,20 @@ const releaseOptions = {
17
34
  describe: 'Promote new release of Node.js',
18
35
  type: 'boolean'
19
36
  },
37
+ releaseDate: {
38
+ describe: 'Default relase date when --prepare is used. It must be YYYY-MM-DD',
39
+ type: 'string'
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
+ },
20
47
  security: {
21
48
  describe: 'Demarcate the new security release as a security release',
22
49
  type: 'boolean'
23
50
  },
24
- filterLabel: {
25
- describe: 'Labels separated by "," to filter security PRs',
26
- type: 'string'
27
- },
28
51
  skipBranchDiff: {
29
52
  describe: 'Skips the initial branch-diff check when preparing releases',
30
53
  type: 'boolean'
@@ -32,6 +55,11 @@ const releaseOptions = {
32
55
  startLTS: {
33
56
  describe: 'Mark the release as the transition from Current to LTS',
34
57
  type: 'boolean'
58
+ },
59
+ yes: {
60
+ type: 'boolean',
61
+ default: false,
62
+ describe: 'Skip all prompts and run non-interactively'
35
63
  }
36
64
  };
37
65
 
@@ -40,11 +68,16 @@ let yargsInstance;
40
68
  export function builder(yargs) {
41
69
  yargsInstance = yargs;
42
70
  return yargs
43
- .options(releaseOptions).positional('newVersion', {
44
- 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'
45
74
  })
46
- .example('git node release --prepare 1.2.3',
47
- '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')
48
81
  .example('git node --prepare --startLTS',
49
82
  'Prepare the first LTS release');
50
83
  }
@@ -66,6 +99,10 @@ function release(state, argv) {
66
99
  const cli = new CLI(logStream);
67
100
  const dir = process.cwd();
68
101
 
102
+ if (argv.yes) {
103
+ cli.setAssumeYes();
104
+ }
105
+
69
106
  return runPromise(main(state, argv, cli, dir)).catch((err) => {
70
107
  if (cli.spinner.enabled) {
71
108
  cli.spinner.fail();
@@ -75,17 +112,21 @@ function release(state, argv) {
75
112
  }
76
113
 
77
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
+ }
78
119
  if (state === PREPARE) {
79
- const prep = new ReleasePreparation(argv, cli, dir);
120
+ const release = new ReleasePreparation(argv, cli, dir);
80
121
 
81
- await prep.prepareLocalBranch();
122
+ await release.prepareLocalBranch();
82
123
 
83
- if (prep.warnForWrongBranch()) return;
124
+ if (release.warnForWrongBranch()) return;
84
125
 
85
126
  // If the new version was automatically calculated, confirm it.
86
127
  if (!argv.newVersion) {
87
128
  const create = await cli.prompt(
88
- `Create release with new version ${prep.newVersion}?`,
129
+ `Create release with new version ${release.newVersion}?`,
89
130
  { defaultAnswer: true });
90
131
 
91
132
  if (!create) {
@@ -94,8 +135,30 @@ async function main(state, argv, cli, dir) {
94
135
  }
95
136
  }
96
137
 
97
- return prep.prepare();
138
+ return release.prepare();
98
139
  } else if (state === PROMOTE) {
99
- // 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();
100
163
  }
101
164
  }
@@ -22,6 +22,7 @@ export function builder(yargs) {
22
22
  default: 'lkgr'
23
23
  });
24
24
  yargs.option('version-bump', {
25
+ type: 'boolean',
25
26
  describe: 'Bump the NODE_MODULE_VERSION constant',
26
27
  default: true
27
28
  });
@@ -39,12 +40,26 @@ export function builder(yargs) {
39
40
  builder: (yargs) => {
40
41
  yargs
41
42
  .option('bump', {
43
+ type: 'boolean',
42
44
  describe: 'Bump V8 embedder version number or patch version',
43
45
  default: true
44
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
+ })
45
58
  .option('squash', {
59
+ type: 'boolean',
46
60
  describe:
47
- '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',
48
63
  default: false
49
64
  });
50
65
  }
@@ -62,8 +77,8 @@ export function builder(yargs) {
62
77
  describe: 'Directory of an existing V8 clone'
63
78
  })
64
79
  .option('verbose', {
80
+ type: 'boolean',
65
81
  describe: 'Enable verbose output',
66
- boolean: true,
67
82
  default: false
68
83
  });
69
84
  }
@@ -85,7 +100,7 @@ export function handler(argv) {
85
100
  input,
86
101
  spawnArgs: {
87
102
  cwd: options.nodeDir,
88
- stdio: input ? ['pipe', 'ignore', 'ignore'] : 'ignore'
103
+ stdio: input ? ['pipe', 'inherit', 'inherit'] : 'inherit'
89
104
  }
90
105
  });
91
106
  };
@@ -94,7 +109,7 @@ export function handler(argv) {
94
109
  return forceRunAsync('git', args, {
95
110
  ignoreFailure: false,
96
111
  captureStdout: true,
97
- spawnArgs: { cwd: options.v8Dir, stdio: ['ignore', 'pipe', 'ignore'] }
112
+ spawnArgs: { cwd: options.v8Dir, stdio: ['ignore', 'pipe', 'inherit'] }
98
113
  });
99
114
  };
100
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
  }
package/lib/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import ora from 'ora';
2
2
  import chalk from 'chalk';
3
- import inquirer from 'inquirer';
3
+ import * as inquirer from '@inquirer/prompts';
4
4
 
5
5
  import { warning, error, info, success } from './figures.js';
6
6
 
@@ -81,12 +81,10 @@ export default class CLI {
81
81
  return defaultAnswer;
82
82
  }
83
83
 
84
- const { answer } = await inquirer.prompt([{
85
- type: questionType,
86
- name: 'answer',
84
+ const answer = await inquirer[questionType]({
87
85
  message: question,
88
86
  default: defaultAnswer
89
- }]);
87
+ });
90
88
 
91
89
  if (isSpinning) {
92
90
  this.spinner.start(spinningMessage);
@@ -25,6 +25,7 @@ export default class ReleasePreparation extends Session {
25
25
  this.isLTS = false;
26
26
  this.isLTSTransition = argv.startLTS;
27
27
  this.runBranchDiff = !argv.skipBranchDiff;
28
+ this.defaultReleaseDate = argv.releaseDate ?? new Date().toISOString().slice(0, 10);
28
29
  this.ltsCodename = '';
29
30
  this.date = '';
30
31
  this.filterLabels = argv.filterLabel && argv.filterLabel.split(',');
@@ -166,7 +167,11 @@ export default class ReleasePreparation extends Session {
166
167
  return this.prepareSecurity();
167
168
  }
168
169
 
169
- 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) {
170
175
  // TODO: UPDATE re-use
171
176
  // Check the branch diff to determine if the releaser
172
177
  // wants to backport any more commits before proceeding.
@@ -241,9 +246,8 @@ export default class ReleasePreparation extends Session {
241
246
  cli.stopSpinner('Updated REPLACEME items in docs');
242
247
 
243
248
  // Fetch date to use in release commit & changelogs.
244
- const todayDate = new Date().toISOString().split('T')[0];
245
249
  this.date = await cli.prompt('Enter release date in YYYY-MM-DD format:',
246
- { questionType: 'input', defaultAnswer: todayDate });
250
+ { questionType: 'input', defaultAnswer: this.defaultReleaseDate });
247
251
 
248
252
  cli.startSpinner('Updating CHANGELOG.md');
249
253
  await this.updateMainChangelog();
@@ -253,9 +257,6 @@ export default class ReleasePreparation extends Session {
253
257
  await this.updateMajorChangelog();
254
258
  cli.stopSpinner(`Updated CHANGELOG_V${versionComponents.major}.md`);
255
259
 
256
- await cli.prompt('Finished editing the changelogs?',
257
- { defaultAnswer: false });
258
-
259
260
  // Create release commit.
260
261
  const shouldCreateReleaseCommit = await cli.prompt(
261
262
  'Create release commit?');
@@ -272,9 +273,7 @@ export default class ReleasePreparation extends Session {
272
273
  cli.warn(`Please manually edit commit ${lastCommitSha} by running ` +
273
274
  '`git commit --amend` before proceeding.');
274
275
 
275
- await cli.prompt(
276
- 'Finished editing the release commit?',
277
- { defaultAnswer: false });
276
+ await cli.prompt('Finished editing the release commit?');
278
277
  }
279
278
 
280
279
  cli.separator();
@@ -625,8 +624,7 @@ export default class ReleasePreparation extends Session {
625
624
  ]);
626
625
 
627
626
  cli.log(`${messageTitle}\n\n${messageBody.join('')}`);
628
- const useMessage = await cli.prompt(
629
- 'Continue with this commit message?', { defaultAnswer: false });
627
+ const useMessage = await cli.prompt('Continue with this commit message?');
630
628
  return useMessage;
631
629
  }
632
630
 
@@ -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/request.js CHANGED
@@ -158,7 +158,13 @@ export default class Request {
158
158
  Accept: 'application/json'
159
159
  }
160
160
  };
161
- return this.json(url, options);
161
+ const data = await this.json(url, options);
162
+ if (data?.errors) {
163
+ throw new Error(
164
+ `Request to fetch triaged reports failed with: ${JSON.stringify(data.errors)}`
165
+ );
166
+ }
167
+ return data;
162
168
  }
163
169
 
164
170
  async getPrograms() {
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
  }
@@ -3,23 +3,22 @@ import {
3
3
  promises as fs
4
4
  } from 'node:fs';
5
5
 
6
- import inquirer from 'inquirer';
6
+ import { confirm } from '@inquirer/prompts';
7
7
  import { ListrEnquirerPromptAdapter } from '@listr2/prompt-adapter-enquirer';
8
8
 
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) {
15
- const { wantSquash } = await inquirer.prompt([{
16
- type: 'confirm',
17
- name: 'wantSquash',
16
+ const wantSquash = await confirm({
18
17
  message: 'Squashing commits should be avoided if possible, because it ' +
19
18
  'can make git bisection difficult. Only squash commits if they would ' +
20
19
  'break the build when applied individually. Are you sure?',
21
20
  default: false
22
- }]);
21
+ });
23
22
 
24
23
  if (!wantSquash) {
25
24
  return true;
@@ -43,6 +42,8 @@ export function doBackport(options) {
43
42
  }
44
43
  }
45
44
  todo.push(commitSquashedBackport());
45
+ } else if (options.preserveOriginalAuthor) {
46
+ todo.push(cherryPickV8Commits(options));
46
47
  } else {
47
48
  todo.push(applyAndCommitPatches());
48
49
  }
@@ -78,18 +79,47 @@ function commitSquashedBackport() {
78
79
  };
79
80
  };
80
81
 
81
- 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) {
82
93
  return {
83
- title: 'Commit patch',
94
+ title: 'Amend/commit',
84
95
  task: async(ctx) => {
85
- const messageTitle = formatMessageTitle([patch]);
86
- const messageBody = formatMessageBody(patch, false);
87
- await ctx.execGitNode('add', ['deps/v8']);
88
- 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);
89
112
  }
90
113
  };
91
114
  }
92
115
 
116
+ function commitPatch(patch) {
117
+ return {
118
+ title: 'Commit patch',
119
+ task: commitTask(patch)
120
+ };
121
+ }
122
+
93
123
  function formatMessageTitle(patches) {
94
124
  const action =
95
125
  patches.some(patch => patch.hadConflicts) ? 'backport' : 'cherry-pick';
@@ -108,12 +138,12 @@ function formatMessageTitle(patches) {
108
138
  }
109
139
  }
110
140
 
111
- function formatMessageBody(patch, prefixTitle) {
141
+ function formatMessageBody(patch, prefixTitle, trailers = '') {
112
142
  const indentedMessage = patch.message.replace(/\n/g, '\n ');
113
143
  const body =
114
144
  'Original commit message:\n\n' +
115
145
  ` ${indentedMessage}\n\n` +
116
- `Refs: https://github.com/v8/v8/commit/${patch.sha}`;
146
+ `Refs: https://github.com/v8/v8/commit/${patch.sha}${trailers}`;
117
147
 
118
148
  if (prefixTitle) {
119
149
  const action = patch.hadConflicts ? 'Backport' : 'Cherry-pick';
@@ -169,6 +199,15 @@ function applyAndCommitPatches() {
169
199
  };
170
200
  }
171
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
+
172
211
  function applyPatchTask(patch) {
173
212
  return {
174
213
  title: `Commit ${shortSha(patch.sha)}`,
@@ -192,10 +231,33 @@ function applyPatchTask(patch) {
192
231
  };
193
232
  }
194
233
 
195
- 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') {
196
258
  try {
197
259
  await ctx.execGitNode(
198
- 'apply',
260
+ method,
199
261
  ['-p1', '--3way', '--directory=deps/v8'],
200
262
  patch.data /* input */
201
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.5.1",
3
+ "version": "5.7.0",
4
4
  "description": "Utilities for Node.js core collaborators",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,36 +34,37 @@
34
34
  ],
35
35
  "license": "MIT",
36
36
  "dependencies": {
37
- "@listr2/prompt-adapter-enquirer": "^2.0.10",
37
+ "@inquirer/prompts": "^6.0.1",
38
+ "@listr2/prompt-adapter-enquirer": "^2.0.11",
38
39
  "@node-core/caritat": "^1.6.0",
39
40
  "@pkgjs/nv": "^0.2.2",
40
- "branch-diff": "^3.0.4",
41
+ "branch-diff": "^3.1.1",
41
42
  "chalk": "^5.3.0",
42
43
  "changelog-maker": "^4.1.1",
43
- "cheerio": "^1.0.0-rc.12",
44
+ "cheerio": "^1.0.0",
44
45
  "clipboardy": "^4.0.0",
45
- "core-validate-commit": "^4.0.0",
46
+ "core-validate-commit": "^4.1.0",
46
47
  "figures": "^6.1.0",
47
- "ghauth": "^6.0.5",
48
- "inquirer": "^9.3.2",
48
+ "ghauth": "^6.0.7",
49
+ "git-secure-tag": "^2.3.1",
49
50
  "js-yaml": "^4.1.0",
50
- "listr2": "^8.2.3",
51
+ "listr2": "^8.2.4",
51
52
  "lodash": "^4.17.21",
52
- "log-symbols": "^6.0.0",
53
- "ora": "^8.0.1",
54
- "replace-in-file": "^8.0.2",
55
- "undici": "^6.19.2",
53
+ "log-symbols": "^7.0.0",
54
+ "ora": "^8.1.0",
55
+ "replace-in-file": "^8.2.0",
56
+ "undici": "^6.19.8",
56
57
  "which": "^4.0.0",
57
58
  "yargs": "^17.7.2"
58
59
  },
59
60
  "devDependencies": {
60
- "@reporters/github": "^1.7.0",
61
+ "@reporters/github": "^1.7.1",
61
62
  "c8": "^10.1.2",
62
- "eslint": "^8.57.0",
63
+ "eslint": "^8.57.1",
63
64
  "eslint-config-standard": "^17.1.0",
64
- "eslint-plugin-import": "^2.29.1",
65
+ "eslint-plugin-import": "^2.30.0",
65
66
  "eslint-plugin-n": "^16.6.2",
66
- "eslint-plugin-promise": "^6.4.0",
67
- "sinon": "^18.0.0"
67
+ "eslint-plugin-promise": "^6.6.0",
68
+ "sinon": "^19.0.2"
68
69
  }
69
70
  }