@node-core/utils 5.12.2 → 5.14.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.
package/README.md CHANGED
@@ -98,7 +98,7 @@ these commands.
98
98
  To obtain the Jenkins API token
99
99
 
100
100
  1. Open
101
- `https://ci.nodejs.org/user/<your-github-username>/configure` (replace
101
+ `https://ci.nodejs.org/user/<your-github-username>/security` (replace
102
102
  \<your-github-username\> with your own GitHub username).
103
103
  2. Click on the `ADD NEW TOKEN` button in the `API Token` section.
104
104
  3. Enter an identifiable name (for example, `node-core-utils`) for this
package/bin/ncu-ci.js CHANGED
@@ -114,7 +114,7 @@ const args = yargs(hideBin(process.argv))
114
114
  describe: 'ID of the PR',
115
115
  type: 'number'
116
116
  })
117
- .positional('certify-safe', {
117
+ .option('certify-safe', {
118
118
  describe: 'SHA of the commit that is expected to be at the tip of the PR head. ' +
119
119
  'If not provided, the command will use the SHA of the last approved commit.',
120
120
  type: 'string'
@@ -6,7 +6,7 @@ import TeamInfo from '../../lib/team_info.js';
6
6
  import Request from '../../lib/request.js';
7
7
  import { runPromise } from '../../lib/run.js';
8
8
 
9
- export const command = 'release [prid|options]';
9
+ export const command = 'release [prid..]';
10
10
  export const describe = 'Manage an in-progress release or start a new one.';
11
11
 
12
12
  const PREPARE = 'prepare';
@@ -34,8 +34,13 @@ const releaseOptions = {
34
34
  describe: 'Promote new release of Node.js',
35
35
  type: 'boolean'
36
36
  },
37
+ fetchFrom: {
38
+ describe: 'Remote to fetch the release proposal(s) from, if different from the one where to' +
39
+ 'push the tags and commits.',
40
+ type: 'string',
41
+ },
37
42
  releaseDate: {
38
- describe: 'Default relase date when --prepare is used. It must be YYYY-MM-DD',
43
+ describe: 'Default release date when --prepare is used. It must be YYYY-MM-DD',
39
44
  type: 'string'
40
45
  },
41
46
  run: {
@@ -112,11 +117,6 @@ function release(state, argv) {
112
117
  }
113
118
 
114
119
  async function main(state, argv, cli, dir) {
115
- const prID = /^(?:https:\/\/github\.com\/nodejs(-private)?\/node\1\/pull\/)?(\d+)$/.exec(argv.prid);
116
- if (prID) {
117
- if (prID[1]) argv.security = true;
118
- argv.prid = Number(prID[2]);
119
- }
120
120
  if (state === PREPARE) {
121
121
  const release = new ReleasePreparation(argv, cli, dir);
122
122
 
@@ -160,6 +160,24 @@ async function main(state, argv, cli, dir) {
160
160
  cli.stopSpinner(`${release.username} is a Releaser`);
161
161
  }
162
162
 
163
- return release.promote();
163
+ const releases = [];
164
+ for (const pr of argv.prid) {
165
+ const match = /^(?:https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/)?(\d+)(?:#.*)?$/.exec(pr);
166
+ if (!match) throw new Error('Invalid PR ID or URL', { cause: pr });
167
+ const [,owner, repo, prid] = match;
168
+
169
+ if (
170
+ owner &&
171
+ (owner !== release.owner || repo !== release.repo) &&
172
+ !argv.fetchFrom
173
+ ) {
174
+ console.warn('The configured owner/repo does not match the PR URL.');
175
+ console.info('You should either pass `--fetch-from` flag or check your configuration');
176
+ console.info(`E.g. --fetch-from=git@github.com:${owner}/${repo}.git`);
177
+ throw new Error('You need to tell what remote use to fetch security release proposal.');
178
+ }
179
+ releases.push(await release.preparePromotion({ owner, repo, prid: Number(prid) }));
180
+ }
181
+ return release.promote(releases);
164
182
  }
165
183
  }
@@ -47,7 +47,8 @@ async function main(argv) {
47
47
  const statusFolder = path.join(nodedir, 'test', 'wpt', 'status');
48
48
  let supported = [
49
49
  'dom',
50
- 'html'
50
+ 'html',
51
+ 'webcrypto'
51
52
  ];
52
53
  if (fs.existsSync(statusFolder)) {
53
54
  const jsons = fs.readdirSync(statusFolder);
@@ -1,23 +1,13 @@
1
- import os from 'node:os';
2
1
  import path from 'node:path';
3
2
  import { getMetadata } from '../components/metadata.js';
4
3
 
5
4
  import {
6
- runAsync, runSync, forceRunAsync
5
+ runAsync, runSync
7
6
  } from './run.js';
8
- import { writeFile } from './file.js';
9
- import {
10
- shortSha, getEditor
11
- } from './utils.js';
12
7
  import { getNcuDir } from './config.js';
8
+ import LandingSession, { LINT_RESULTS } from './landing_session.js';
13
9
 
14
- const LINT_RESULTS = {
15
- SKIPPED: 'skipped',
16
- FAILED: 'failed',
17
- SUCCESS: 'success'
18
- };
19
-
20
- export default class CheckPick {
10
+ export default class CherryPick {
21
11
  constructor(prid, dir, cli, {
22
12
  owner,
23
13
  repo,
@@ -46,11 +36,6 @@ export default class CheckPick {
46
36
  return this.options.lint;
47
37
  }
48
38
 
49
- getUpstreamHead() {
50
- const { upstream, branch } = this;
51
- return runSync('git', ['rev-parse', `${upstream}/${branch}`]).trim();
52
- }
53
-
54
39
  getCurrentRev() {
55
40
  return runSync('git', ['rev-parse', 'HEAD']).trim();
56
41
  }
@@ -73,16 +58,6 @@ export default class CheckPick {
73
58
  return path.resolve(this.ncuDir, `${this.prid}`);
74
59
  }
75
60
 
76
- getMessagePath(rev) {
77
- return path.resolve(this.pullDir, `${shortSha(rev)}.COMMIT_EDITMSG`);
78
- }
79
-
80
- saveMessage(rev, message) {
81
- const file = this.getMessagePath(rev);
82
- writeFile(file, message);
83
- return file;
84
- }
85
-
86
61
  async start() {
87
62
  const { cli } = this;
88
63
 
@@ -91,7 +66,7 @@ export default class CheckPick {
91
66
  owner: this.owner,
92
67
  repo: this.repo
93
68
  }, false, cli);
94
- const expectedCommitShas =
69
+ this.expectedCommitShas =
95
70
  metadata.data.commits.map(({ commit }) => commit.oid);
96
71
 
97
72
  const amend = await cli.prompt(
@@ -104,7 +79,7 @@ export default class CheckPick {
104
79
  }
105
80
 
106
81
  try {
107
- const commitInfo = await this.downloadAndPatch(expectedCommitShas);
82
+ const commitInfo = await this.downloadAndPatch();
108
83
  const cleanLint = await this.validateLint();
109
84
  if (cleanLint === LINT_RESULTS.FAILED) {
110
85
  cli.error('Patch still contains lint errors. ' +
@@ -120,68 +95,6 @@ export default class CheckPick {
120
95
  }
121
96
  }
122
97
 
123
- async downloadAndPatch(expectedCommitShas) {
124
- const { cli, repo, owner, prid } = this;
125
-
126
- cli.startSpinner(`Downloading patch for ${prid}`);
127
- // fetch via ssh to handle private repo
128
- await runAsync('git', [
129
- 'fetch', `git@github.com:${owner}/${repo}.git`,
130
- `refs/pull/${prid}/merge`]);
131
- // We fetched the commit that would result if we used `git merge`.
132
- // ^1 and ^2 refer to the PR base and the PR head, respectively.
133
- const [base, head] = await runAsync('git',
134
- ['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2'],
135
- { captureStdout: 'lines' });
136
- const commitShas = await runAsync('git',
137
- ['rev-list', `${base}..${head}`],
138
- { captureStdout: 'lines' });
139
- cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`);
140
- cli.separator();
141
-
142
- const mismatchedCommits = [
143
- ...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
144
- .map((sha) => `Unexpected commit ${sha}`),
145
- ...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
146
- .map((sha) => `Missing commit ${sha}`)
147
- ].join('\n');
148
- if (mismatchedCommits.length > 0) {
149
- throw new Error(`Mismatched commits:\n${mismatchedCommits}`);
150
- }
151
-
152
- const commitInfo = { base, head, shas: commitShas };
153
-
154
- try {
155
- await forceRunAsync('git', ['cherry-pick', `${base}..${head}`], {
156
- ignoreFailure: false
157
- });
158
- } catch (ex) {
159
- await forceRunAsync('git', ['cherry-pick', '--abort']);
160
- throw new Error('Failed to apply patches');
161
- }
162
-
163
- cli.ok('Patches applied');
164
- return commitInfo;
165
- }
166
-
167
- async validateLint() {
168
- // The linter is currently only run on non-Windows platforms.
169
- if (os.platform() === 'win32') {
170
- return LINT_RESULTS.SKIPPED;
171
- }
172
-
173
- if (!this.lint) {
174
- return LINT_RESULTS.SKIPPED;
175
- }
176
-
177
- try {
178
- await runAsync('make', ['lint']);
179
- return LINT_RESULTS.SUCCESS;
180
- } catch {
181
- return LINT_RESULTS.FAILED;
182
- }
183
- }
184
-
185
98
  async amend(metadata, commitInfo) {
186
99
  const { cli } = this;
187
100
  const subjects = await runAsync('git',
@@ -203,102 +116,23 @@ export default class CheckPick {
203
116
  await runAsync('git', ['commit', '--amend', '--no-edit']);
204
117
  }
205
118
 
206
- return this._amend(metadata);
119
+ return LandingSession.prototype.amend.call(this, metadata);
207
120
  }
208
121
 
209
- async _amend(metadataStr) {
210
- const { cli } = this;
211
-
212
- const rev = this.getCurrentRev();
213
- const original = runSync('git', [
214
- 'show', 'HEAD', '-s', '--format=%B'
215
- ]).trim();
216
- // git has very specific rules about what is a trailer and what is not.
217
- // Instead of trying to implement those ourselves, let git parse the
218
- // original commit message and see if it outputs any trailers.
219
- const originalHasTrailers = runSync('git', [
220
- 'interpret-trailers', '--parse', '--no-divider'
221
- ], {
222
- input: `${original}\n`
223
- }).trim().length !== 0;
224
- const metadata = metadataStr.trim().split('\n');
225
- const amended = original.split('\n');
226
-
227
- // If the original commit message already contains trailers (such as
228
- // "Co-authored-by"), we simply add our own metadata after those. Otherwise,
229
- // we have to add an empty line so that git recognizes our own metadata as
230
- // trailers in the amended commit message.
231
- if (!originalHasTrailers) {
232
- amended.push('');
233
- }
234
-
235
- const BACKPORT_RE = /BACKPORT-PR-URL\s*:\s*(\S+)/i;
236
- const PR_RE = /PR-URL\s*:\s*(\S+)/i;
237
- const REVIEW_RE = /Reviewed-By\s*:\s*(\S+)/i;
238
- const CVE_RE = /CVE-ID\s*:\s*(\S+)/i;
239
-
240
- let containCVETrailer = false;
241
- for (const line of metadata) {
242
- if (line.length !== 0 && original.includes(line)) {
243
- if (line.match(CVE_RE)) {
244
- containCVETrailer = true;
245
- }
246
- if (originalHasTrailers) {
247
- cli.warn(`Found ${line}, skipping..`);
248
- } else {
249
- throw new Error(
250
- 'Git found no trailers in the original commit message, ' +
251
- `but '${line}' is present and should be a trailer.`);
252
- }
253
- } else {
254
- if (line.match(BACKPORT_RE)) {
255
- let prIndex = amended.findIndex(datum => datum.match(PR_RE));
256
- if (prIndex === -1) {
257
- prIndex = amended.findIndex(datum => datum.match(REVIEW_RE)) - 1;
258
- }
259
- amended.splice(prIndex + 1, 0, line);
260
- } else {
261
- amended.push(line);
262
- }
263
- }
264
- }
265
-
266
- if (!containCVETrailer && this.includeCVE) {
267
- const cveID = await cli.prompt(
268
- 'Git found no CVE-ID trailer in the original commit message. ' +
269
- 'Please, provide the CVE-ID',
270
- { questionType: 'input', defaultAnswer: 'CVE-2023-XXXXX' }
271
- );
272
- amended.push('CVE-ID: ' + cveID);
273
- }
122
+ readyToAmend() {
123
+ return true;
124
+ }
274
125
 
275
- const message = amended.join('\n');
276
- const messageFile = this.saveMessage(rev, message);
277
- cli.separator('New Message');
278
- cli.log(message.trim());
279
- const takeMessage = await cli.prompt('Use this message?');
280
- if (takeMessage) {
281
- await runAsync('git', ['commit', '--amend', '-F', messageFile]);
282
- return true;
283
- }
126
+ startAmending() {
127
+ // No-op
128
+ }
284
129
 
285
- const editor = await getEditor({ git: true });
286
- if (editor) {
287
- try {
288
- await forceRunAsync(
289
- editor,
290
- [`"${messageFile}"`],
291
- { ignoreFailure: false, spawnArgs: { shell: true } }
292
- );
293
- await runAsync('git', ['commit', '--amend', '-F', messageFile]);
294
- return true;
295
- } catch {
296
- cli.warn(`Please manually edit ${messageFile}, then run\n` +
297
- `\`git commit --amend -F ${messageFile}\` ` +
298
- 'to finish amending the message');
299
- throw new Error(
300
- 'Failed to edit the message using the configured editor');
301
- }
302
- }
130
+ saveCommitInfo() {
131
+ // No-op
303
132
  }
304
133
  }
134
+
135
+ CherryPick.prototype.downloadAndPatch = LandingSession.prototype.downloadAndPatch;
136
+ CherryPick.prototype.validateLint = LandingSession.prototype.validateLint;
137
+ CherryPick.prototype.getMessagePath = LandingSession.prototype.getMessagePath;
138
+ CherryPick.prototype.saveMessage = LandingSession.prototype.saveMessage;
package/lib/ci/run_ci.js CHANGED
@@ -62,7 +62,8 @@ export class RunPRJob {
62
62
  parameter: [
63
63
  { name: 'GITHUB_ORG', value: this.owner },
64
64
  { name: 'REPO_NAME', value: this.repo },
65
- { name: 'GIT_REMOTE_REF', value: `refs/pull/${this.prid}/head` }
65
+ { name: 'GIT_REMOTE_REF', value: `refs/pull/${this.prid}/head` },
66
+ { name: 'COMMIT_SHA_CHECK', value: this.certifySafe }
66
67
  ]
67
68
  }));
68
69
  return payload;
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  const isWindows = process.platform === 'win32';
12
12
 
13
- const LINT_RESULTS = {
13
+ export const LINT_RESULTS = {
14
14
  SKIPPED: 'skipped',
15
15
  FAILED: 'failed',
16
16
  SUCCESS: 'success'
@@ -84,11 +84,11 @@ export default class LandingSession extends Session {
84
84
  }
85
85
 
86
86
  async downloadAndPatch() {
87
- const { cli, repo, owner, prid, expectedCommitShas } = this;
87
+ const { cli, upstream, prid, expectedCommitShas } = this;
88
88
 
89
89
  cli.startSpinner(`Downloading patch for ${prid}`);
90
90
  await runAsync('git', [
91
- 'fetch', `https://github.com/${owner}/${repo}.git`,
91
+ 'fetch', upstream,
92
92
  `refs/pull/${prid}/merge`]);
93
93
  // We fetched the commit that would result if we used `git merge`.
94
94
  // ^1 and ^2 refer to the PR base and the PR head, respectively.
@@ -315,9 +315,14 @@ export default class LandingSession extends Session {
315
315
  const BACKPORT_RE = /BACKPORT-PR-URL\s*:\s*(\S+)/i;
316
316
  const PR_RE = /PR-URL\s*:\s*(\S+)/i;
317
317
  const REVIEW_RE = /Reviewed-By\s*:\s*(\S+)/i;
318
+ const CVE_RE = /CVE-ID\s*:\s*(\S+)/i;
318
319
 
320
+ let containCVETrailer = false;
319
321
  for (const line of metadata) {
320
322
  if (line.length !== 0 && original.includes(line)) {
323
+ if (line.match(CVE_RE)) {
324
+ containCVETrailer = true;
325
+ }
321
326
  if (originalHasTrailers) {
322
327
  cli.warn(`Found ${line}, skipping..`);
323
328
  } else {
@@ -338,6 +343,15 @@ export default class LandingSession extends Session {
338
343
  }
339
344
  }
340
345
 
346
+ if (!containCVETrailer && this.includeCVE) {
347
+ const cveID = await cli.prompt(
348
+ 'Git found no CVE-ID trailer in the original commit message. ' +
349
+ 'Please, provide the CVE-ID',
350
+ { questionType: 'input', defaultAnswer: 'CVE-2023-XXXXX' }
351
+ );
352
+ amended.push('CVE-ID: ' + cveID);
353
+ }
354
+
341
355
  const message = amended.join('\n');
342
356
  const messageFile = this.saveMessage(rev, message);
343
357
  cli.separator('New Message');
package/lib/pr_checker.js CHANGED
@@ -30,15 +30,6 @@ const FAST_TRACK_RE = /^Fast-track has been requested by @(.+?)\. Please 👍 to
30
30
  const FAST_TRACK_MIN_APPROVALS = 2;
31
31
  const GIT_CONFIG_GUIDE_URL = 'https://github.com/nodejs/node/blob/99b1ada/doc/guides/contributing/pull-requests.md#step-1-fork';
32
32
 
33
- // eslint-disable-next-line no-extend-native
34
- Array.prototype.findLastIndex ??= function findLastIndex(fn) {
35
- const reversedIndex = Reflect.apply(
36
- Array.prototype.findIndex,
37
- this.slice().reverse(),
38
- arguments);
39
- return reversedIndex === -1 ? -1 : this.length - reversedIndex - 1;
40
- };
41
-
42
33
  export default class PRChecker {
43
34
  /**
44
35
  * @param {{}} cli
@@ -49,7 +40,7 @@ export default class PRChecker {
49
40
  this.request = request;
50
41
  this.data = data;
51
42
  const {
52
- pr, reviewers, comments, reviews, commits, collaborators
43
+ pr, reviewers, comments, reviews, commits
53
44
  } = data;
54
45
  this.reviewers = reviewers;
55
46
  this.pr = pr;
@@ -61,9 +52,6 @@ export default class PRChecker {
61
52
  this.reviews = reviews;
62
53
  this.commits = commits;
63
54
  this.argv = argv;
64
- this.collaboratorEmails = new Set(
65
- Array.from(collaborators).map((c) => c[1].email)
66
- );
67
55
  }
68
56
 
69
57
  get waitTimeSingleApproval() {
@@ -70,7 +70,7 @@ export default class PrepareSecurityRelease extends SecurityRelease {
70
70
  { cli: this.cli, repository: this.repository }
71
71
  );
72
72
  }
73
- this.cli.info(`Merge pull request with:
73
+ this.cli.info(`If the PR is ready (CI is passing): merge pull request with:
74
74
  - git checkout main
75
75
  - git merge ${NEXT_SECURITY_RELEASE_BRANCH} --no-ff -m "chore: add latest security release"
76
76
  - git push origin main`);
@@ -17,16 +17,10 @@ const dryRunMessage = 'You are running in dry-run mode, meaning NCU will not run
17
17
 
18
18
  export default class ReleasePromotion extends Session {
19
19
  constructor(argv, req, cli, dir) {
20
- super(cli, dir, argv.prid);
20
+ super(cli, dir);
21
21
  this.req = req;
22
- if (argv.security) {
23
- this.config.owner = 'nodejs-private';
24
- this.config.repo = 'node-private';
25
- }
26
22
  this.dryRun = !argv.run;
27
- this.isLTS = false;
28
- this.ltsCodename = '';
29
- this.date = '';
23
+ this.proposalUpstreamRemote = argv.fetchFrom ?? this.upstream;
30
24
  this.gpgSign = argv?.['gpg-sign']
31
25
  ? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
32
26
  : [];
@@ -43,8 +37,8 @@ export default class ReleasePromotion extends Session {
43
37
  return defaultBranchRef.name;
44
38
  }
45
39
 
46
- async promote() {
47
- const { prid, cli } = this;
40
+ async preparePromotion({ prid, owner, repo }) {
41
+ const { cli, proposalUpstreamRemote } = this;
48
42
 
49
43
  // In the promotion stage, we can pull most relevant data
50
44
  // from the release commit created in the preparation stage.
@@ -54,9 +48,7 @@ export default class ReleasePromotion extends Session {
54
48
  isApproved,
55
49
  jenkinsReady,
56
50
  releaseCommitSha
57
- } = await this.verifyPRAttributes();
58
-
59
- this.releaseCommitSha = releaseCommitSha;
51
+ } = await this.verifyPRAttributes({ prid, owner, repo });
60
52
 
61
53
  let localCloneIsClean = true;
62
54
  const currentHEAD = await forceRunAsync('git', ['rev-parse', 'HEAD'],
@@ -74,7 +66,7 @@ export default class ReleasePromotion extends Session {
74
66
  if (!localCloneIsClean) {
75
67
  if (await cli.prompt('Should we reset the local HEAD to be the release proposal?')) {
76
68
  cli.startSpinner('Fetching the proposal upstream...');
77
- await forceRunAsync('git', ['fetch', this.upstream, releaseCommitSha],
69
+ await forceRunAsync('git', ['fetch', proposalUpstreamRemote, releaseCommitSha],
78
70
  { ignoreFailure: false });
79
71
  await forceRunAsync('git', ['reset', releaseCommitSha, '--hard'], { ignoreFailure: false });
80
72
  cli.stopSpinner('Local HEAD is now in sync with the proposal');
@@ -84,9 +76,9 @@ export default class ReleasePromotion extends Session {
84
76
  }
85
77
  }
86
78
 
87
- await this.parseDataFromReleaseCommit();
79
+ const releaseData = await this.parseDataFromReleaseCommit(releaseCommitSha);
88
80
 
89
- const { version } = this;
81
+ const { version, isLTS, ltsCodename, date, versionComponents } = releaseData;
90
82
  cli.startSpinner('Verifying Jenkins CI status');
91
83
  if (!jenkinsReady) {
92
84
  cli.stopSpinner(
@@ -134,102 +126,79 @@ export default class ReleasePromotion extends Session {
134
126
  cli.warn(`Aborting release promotion for version ${version}`);
135
127
  throw new Error('Aborted');
136
128
  }
137
- await this.secureTagRelease();
138
- await this.verifyTagSignature();
129
+ await this.secureTagRelease({ version, isLTS, ltsCodename, releaseCommitSha, date });
130
+ await this.verifyTagSignature(version);
139
131
 
140
132
  // Set up for next release.
141
133
  cli.startSpinner('Setting up for next release');
142
- const workingOnNewReleaseCommit = await this.setupForNextRelease();
134
+ const workingOnNewReleaseCommit =
135
+ await this.setupForNextRelease({ prid, owner, repo, versionComponents });
143
136
  cli.stopSpinner('Successfully set up for next release');
144
137
 
138
+ const shouldRebaseStagingBranch = await cli.prompt(
139
+ 'Rebase staging branch on top of the release commit?', { defaultAnswer: true });
140
+ const tipOfStagingBranch = shouldRebaseStagingBranch
141
+ ? await this.rebaseRemoteBranch(
142
+ `v${versionComponents.major}.x-staging`,
143
+ workingOnNewReleaseCommit)
144
+ : workingOnNewReleaseCommit;
145
+
146
+ return { releaseCommitSha, workingOnNewReleaseCommit, tipOfStagingBranch, ...releaseData };
147
+ }
148
+
149
+ async promote(releases) {
150
+ const { cli } = this;
151
+
145
152
  // Cherry pick release commit to master.
146
153
  const shouldCherryPick = await cli.prompt(
147
- 'Cherry-pick release commit to the default branch?', { defaultAnswer: true });
154
+ 'Cherry-pick release commit(s) to the default branch?', { defaultAnswer: true });
148
155
  if (!shouldCherryPick) {
149
- cli.warn(`Aborting release promotion for version ${version}`);
150
156
  throw new Error('Aborted');
151
157
  }
152
- const appliedCleanly = await this.cherryPickToDefaultBranch();
153
-
154
- // Ensure `node_version.h`'s `NODE_VERSION_IS_RELEASE` bit is not updated
155
- await forceRunAsync('git', ['checkout',
156
- appliedCleanly
157
- ? 'HEAD^' // In the absence of conflict, the top of the remote branch is the commit before.
158
- : 'HEAD', // In case of conflict, HEAD is still the top of the remove branch.
159
- '--', 'src/node_version.h'],
160
- { ignoreFailure: false });
161
-
162
- if (appliedCleanly) {
163
- // There were no conflicts, we have to amend the commit to revert the
164
- // `node_version.h` changes.
165
- await forceRunAsync('git', ['commit', ...this.gpgSign, '--amend', '--no-edit', '-n'],
166
- { ignoreFailure: false });
167
- } else {
168
- // There will be remaining cherry-pick conflicts the Releaser will
169
- // need to resolve, so confirm they've been resolved before
170
- // proceeding with next steps.
171
- cli.separator();
172
- cli.info('Resolve the conflicts and commit the result');
173
- cli.separator();
174
- const didResolveConflicts = await cli.prompt(
175
- 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
176
- if (!didResolveConflicts) {
177
- cli.warn(`Aborting release promotion for version ${version}`);
178
- throw new Error('Aborted');
179
- }
180
- }
158
+ const defaultBranch = await this.checkoutDefaultBranch();
181
159
 
182
- if (existsSync('.git/CHERRY_PICK_HEAD')) {
183
- cli.info('Cherry-pick is still in progress, attempting to continue it.');
184
- await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, '--continue'],
185
- { ignoreFailure: false });
186
- }
187
-
188
- // Validate release commit on the default branch
189
- const releaseCommitOnDefaultBranch =
190
- await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
191
- { captureStdout: true, ignoreFailure: false });
192
- const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
193
- await this.validateReleaseCommit(commitTitle);
194
- if (modifiedFiles.some(file => !file.endsWith('.md'))) {
195
- cli.warn('Some modified files are not markdown, that\'s unusual.');
196
- cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
197
- if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
198
- throw new Error('Aborted');
199
- }
160
+ for (const { releaseCommitSha } of releases) {
161
+ await this.cherryPickReleaseCommit(releaseCommitSha);
200
162
  }
201
163
 
202
- // Push to the remote the release tag, and default, release, and staging branch.
203
- await this.pushToRemote(workingOnNewReleaseCommit);
164
+ // Push to the remote the release tag(s), and default, release, and staging branches.
165
+ await this.pushToRemote(this.upstream, defaultBranch, ...releases.flatMap(
166
+ ({ version, versionComponents, workingOnNewReleaseCommit, tipOfStagingBranch }) => [
167
+ `v${version}`,
168
+ `${workingOnNewReleaseCommit}:refs/heads/v${versionComponents.major}.x`,
169
+ `+${tipOfStagingBranch}:refs/heads/v${versionComponents.major}.x-staging`,
170
+ ]));
204
171
 
205
172
  // Promote and sign the release builds.
206
173
  await this.promoteAndSignRelease();
207
174
 
208
175
  cli.separator();
209
- cli.ok(`Release promotion for ${version} complete.\n`);
176
+ cli.ok('Release promotion(s) complete.\n');
210
177
  cli.info(
211
178
  'To finish this release, you\'ll need to: \n' +
212
- ` 1. Check the release at: https://nodejs.org/dist/v${version}\n` +
213
- ' 2. Create the blog post for nodejs.org.\n' +
214
- ' 3. Create the release on GitHub.\n' +
215
- ' 4. Optionally, announce the release on your social networks.\n' +
179
+ ' 1. Check the release(s) at: https://nodejs.org/dist/v{version}\n' +
180
+ ' 2. Create the blog post(s) for nodejs.org.\n' +
181
+ ' 3. Create the release(s) on GitHub.\n' +
182
+ ' 4. Optionally, announce the release(s) on your social networks.\n' +
216
183
  ' 5. Tag @nodejs-social-team on #nodejs-release Slack channel.\n');
217
184
 
218
185
  cli.separator();
219
- cli.info('Use the following command to create the GitHub release:');
186
+ cli.info('Use the following command(s) to create the GitHub release(s):');
220
187
  cli.separator();
221
- cli.info(
222
- 'awk \'' +
223
- `/^## ${this.date}, Version ${this.version.replaceAll('.', '\\.')} /,` +
224
- '/^<a id="[0-9]+\\.[0-9]+\\.[0-9]+"><\\x2fa>$/{' +
225
- 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' +
226
- `}' doc/changelogs/CHANGELOG_V${
227
- this.versionComponents.major}.md | gh release create v${this.version} --verify-tag --latest${
228
- this.isLTS ? '=false' : ''} --title=${JSON.stringify(this.releaseTitle)} --notes-file -`);
188
+ for (const { date, version, versionComponents, isLTS, releaseTitle } of releases) {
189
+ cli.info(
190
+ 'awk \'' +
191
+ `/^## ${date}, Version ${version.replaceAll('.', '\\.')} /,` +
192
+ '/^<a id="[0-9]+\\.[0-9]+\\.[0-9]+"><\\x2fa>$/{' +
193
+ 'print buf; if(firstLine == "") firstLine = $0; else buf = $0' +
194
+ `}' doc/changelogs/CHANGELOG_V${
195
+ versionComponents.major}.md | gh release create v${version} --verify-tag --latest${
196
+ isLTS ? '=false' : ''} --title=${JSON.stringify(releaseTitle)} --notes-file -`);
197
+ }
229
198
  }
230
199
 
231
- async verifyTagSignature() {
232
- const { cli, version } = this;
200
+ async verifyTagSignature(version) {
201
+ const { cli } = this;
233
202
  const verifyTagPattern = /gpg:[^\n]+\ngpg:\s+using RSA key ([^\n]+)\ngpg:\s+issuer "([^"]+)"\ngpg:\s+Good signature from "([^<]+) <\2>"/;
234
203
  const [verifyTagOutput, haystack] = await Promise.all([forceRunAsync(
235
204
  'git', ['--no-pager',
@@ -258,8 +227,8 @@ export default class ReleasePromotion extends Session {
258
227
  }
259
228
  }
260
229
 
261
- async verifyPRAttributes() {
262
- const { cli, prid, owner, repo, req } = this;
230
+ async verifyPRAttributes({ prid, owner, repo }) {
231
+ const { cli, req } = this;
263
232
 
264
233
  const data = new PRData({ prid, owner, repo }, cli, req);
265
234
  await data.getAll();
@@ -325,8 +294,8 @@ export default class ReleasePromotion extends Session {
325
294
  return data;
326
295
  }
327
296
 
328
- async parseDataFromReleaseCommit() {
329
- const { cli, releaseCommitSha } = this;
297
+ async parseDataFromReleaseCommit(releaseCommitSha) {
298
+ const { cli } = this;
330
299
 
331
300
  const releaseCommitMessage = await forceRunAsync('git', [
332
301
  '--no-pager', 'log', '-1',
@@ -338,26 +307,19 @@ export default class ReleasePromotion extends Session {
338
307
 
339
308
  const releaseCommitData = await this.validateReleaseCommit(releaseCommitMessage);
340
309
 
341
- this.date = releaseCommitData.date;
342
- this.version = releaseCommitData.version;
343
- this.stagingBranch = releaseCommitData.stagingBranch;
344
- this.versionComponents = releaseCommitData.versionComponents;
345
- this.isLTS = releaseCommitData.isLTS;
346
- this.ltsCodename = releaseCommitData.ltsCodename;
347
-
348
310
  // Check if CHANGELOG show the correct releaser for the current release
349
311
  const changeLogDiff = await forceRunAsync('git', [
350
312
  '--no-pager', 'diff',
351
- `${this.releaseCommitSha}^..${this.releaseCommitSha}`,
313
+ `${releaseCommitSha}^..${releaseCommitSha}`,
352
314
  '--',
353
- `doc/changelogs/CHANGELOG_V${this.versionComponents.major}.md`
315
+ `doc/changelogs/CHANGELOG_V${releaseCommitData.versionComponents.major}.md`
354
316
  ], { captureStdout: true, ignoreFailure: false });
355
317
  const headingLine = /^\+## \d{4}-\d{2}-\d{2}, Version \d.+$/m.exec(changeLogDiff);
356
318
  if (headingLine == null) {
357
319
  cli.error('Cannot find section for the new release in CHANGELOG');
358
320
  throw new Error('Aborted');
359
321
  }
360
- this.releaseTitle = headingLine[0].slice(4);
322
+ releaseCommitData.releaseTitle = headingLine[0].slice(4);
361
323
  const expectedLine = `+## ${releaseCommitMessage}, @${this.username}`;
362
324
  if (headingLine[0] !== expectedLine &&
363
325
  !headingLine[0].startsWith(`${expectedLine} prepared by @`)) {
@@ -370,11 +332,11 @@ export default class ReleasePromotion extends Session {
370
332
  throw new Error('Aborted');
371
333
  }
372
334
  }
373
- }
374
335
 
375
- async secureTagRelease() {
376
- const { version, isLTS, ltsCodename, releaseCommitSha } = this;
336
+ return releaseCommitData;
337
+ }
377
338
 
339
+ async secureTagRelease({ version, isLTS, ltsCodename, releaseCommitSha, date }) {
378
340
  const releaseInfo = isLTS ? `${ltsCodename} (LTS)` : '(Current)';
379
341
 
380
342
  try {
@@ -382,7 +344,7 @@ export default class ReleasePromotion extends Session {
382
344
  const api = new gst.API(process.cwd());
383
345
  api.sign(`v${version}`, releaseCommitSha, {
384
346
  insecure: false,
385
- m: `${this.date} Node.js v${version} ${releaseInfo} Release`
347
+ m: `${date} Node.js v${version} ${releaseInfo} Release`
386
348
  }, (err) => err ? reject(err) : resolve());
387
349
  });
388
350
  } catch (err) {
@@ -401,9 +363,7 @@ export default class ReleasePromotion extends Session {
401
363
 
402
364
  // Set up the branch so that nightly builds are produced with the next
403
365
  // version number and a pre-release tag.
404
- async setupForNextRelease() {
405
- const { versionComponents, prid, owner, repo } = this;
406
-
366
+ async setupForNextRelease({ prid, owner, repo, versionComponents }) {
407
367
  // Update node_version.h for next patch release.
408
368
  const filePath = path.resolve('src', 'node_version.h');
409
369
  const nodeVersionFile = await fs.open(filePath, 'r+');
@@ -440,22 +400,16 @@ export default class ReleasePromotion extends Session {
440
400
  return workingOnNewReleaseCommit.trim();
441
401
  }
442
402
 
443
- async pushToRemote(workingOnNewReleaseCommit) {
444
- const { cli, dryRun, version, versionComponents, stagingBranch } = this;
445
- const releaseBranch = `v${versionComponents.major}.x`;
446
- const tagVersion = `v${version}`;
403
+ async pushToRemote(upstream, ...refs) {
404
+ const { cli, dryRun } = this;
447
405
 
448
406
  this.defaultBranch ??= await this.getDefaultBranch();
449
407
 
450
- let prompt = `Push release tag and commits to ${this.upstream}?`;
408
+ let prompt = `Push release tag and commits to ${upstream}?`;
451
409
  if (dryRun) {
452
410
  cli.info(dryRunMessage);
453
411
  cli.info('Run the following command to push to remote:');
454
- cli.info(`git push ${this.upstream} ${
455
- this.defaultBranch} ${
456
- tagVersion} ${
457
- workingOnNewReleaseCommit}:refs/heads/${releaseBranch} ${
458
- workingOnNewReleaseCommit}:refs/heads/${stagingBranch}`);
412
+ cli.info(`git push ${upstream} ${refs.join(' ')}`);
459
413
  cli.warn('Once pushed, you must not delete the local tag');
460
414
  prompt = 'Ready to continue?';
461
415
  }
@@ -469,12 +423,8 @@ export default class ReleasePromotion extends Session {
469
423
  }
470
424
 
471
425
  cli.startSpinner('Pushing to remote');
472
- await forceRunAsync('git', ['push', this.upstream, this.defaultBranch, tagVersion,
473
- `${workingOnNewReleaseCommit}:refs/heads/${releaseBranch}`,
474
- `${workingOnNewReleaseCommit}:refs/heads/${stagingBranch}`],
475
- { ignoreFailure: false });
476
- cli.stopSpinner(`Pushed ${tagVersion}, ${this.defaultBranch}, ${
477
- releaseBranch}, and ${stagingBranch} to remote`);
426
+ await forceRunAsync('git', ['push', upstream, ...refs], { ignoreFailure: false });
427
+ cli.stopSpinner(`Pushed ${JSON.stringify(refs)} to remote`);
478
428
  cli.warn('Now that it has been pushed, you must not delete the local tag');
479
429
  }
480
430
 
@@ -507,21 +457,91 @@ export default class ReleasePromotion extends Session {
507
457
  cli.stopSpinner('Release has been signed and promoted');
508
458
  }
509
459
 
510
- async cherryPickToDefaultBranch() {
460
+ async rebaseRemoteBranch(branchName, workingOnNewReleaseCommit) {
461
+ const { cli, upstream } = this;
462
+ cli.startSpinner('Fetch staging branch');
463
+ await forceRunAsync('git', ['fetch', upstream, branchName], { ignoreFailure: false });
464
+ cli.updateSpinner('Reset and rebase');
465
+ await forceRunAsync('git', ['reset', 'FETCH_HEAD', '--hard'], { ignoreFailure: false });
466
+ await forceRunAsync('git',
467
+ ['rebase', workingOnNewReleaseCommit, ...this.gpgSign], { ignoreFailure: false });
468
+ const tipOfStagingBranch = await forceRunAsync('git', ['rev-parse', 'HEAD'],
469
+ { ignoreFailure: false, captureStdout: true });
470
+ cli.stopSpinner('Rebased successfully');
471
+
472
+ return tipOfStagingBranch.trim();
473
+ }
474
+
475
+ async checkoutDefaultBranch() {
511
476
  this.defaultBranch ??= await this.getDefaultBranch();
512
- const releaseCommitSha = this.releaseCommitSha;
513
477
  await forceRunAsync('git', ['checkout', this.defaultBranch], { ignoreFailure: false });
514
478
 
515
479
  await this.tryResetBranch();
516
480
 
481
+ return this.defaultBranch;
482
+ }
483
+
484
+ async cherryPick(commit) {
517
485
  // There might be conflicts, we do not want to treat this as a hard failure,
518
486
  // but we want to retain that information.
519
487
  try {
520
- await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, releaseCommitSha],
488
+ await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, commit],
521
489
  { ignoreFailure: false });
522
490
  return true;
523
491
  } catch {
524
492
  return false;
525
493
  }
526
494
  }
495
+
496
+ async cherryPickReleaseCommit(releaseCommitSha) {
497
+ const { cli } = this;
498
+
499
+ const appliedCleanly = await this.cherryPick(releaseCommitSha);
500
+ // Ensure `node_version.h`'s `NODE_VERSION_IS_RELEASE` bit is not updated
501
+ await forceRunAsync('git', ['checkout',
502
+ appliedCleanly
503
+ ? 'HEAD^' // In the absence of conflict, the top of the remote branch is the commit before.
504
+ : 'HEAD', // In case of conflict, HEAD is still the top of the remove branch.
505
+ '--', 'src/node_version.h'],
506
+ { ignoreFailure: false });
507
+
508
+ if (appliedCleanly) {
509
+ // There were no conflicts, we have to amend the commit to revert the
510
+ // `node_version.h` changes.
511
+ await forceRunAsync('git', ['commit', ...this.gpgSign, '--amend', '--no-edit', '-n'],
512
+ { ignoreFailure: false });
513
+ } else {
514
+ // There will be remaining cherry-pick conflicts the Releaser will
515
+ // need to resolve, so confirm they've been resolved before
516
+ // proceeding with next steps.
517
+ cli.separator();
518
+ cli.info('Resolve the conflicts and commit the result');
519
+ cli.separator();
520
+ const didResolveConflicts = await cli.prompt(
521
+ 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
522
+ if (!didResolveConflicts) {
523
+ throw new Error('Aborted');
524
+ }
525
+ }
526
+
527
+ if (existsSync('.git/CHERRY_PICK_HEAD')) {
528
+ cli.info('Cherry-pick is still in progress, attempting to continue it.');
529
+ await forceRunAsync('git', ['cherry-pick', ...this.gpgSign, '--continue'],
530
+ { ignoreFailure: false });
531
+ }
532
+
533
+ // Validate release commit on the default branch
534
+ const releaseCommitOnDefaultBranch =
535
+ await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
536
+ { captureStdout: true, ignoreFailure: false });
537
+ const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
538
+ await this.validateReleaseCommit(commitTitle);
539
+ if (modifiedFiles.some(file => !file.endsWith('.md'))) {
540
+ cli.warn('Some modified files are not markdown, that\'s unusual.');
541
+ cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
542
+ if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
543
+ throw new Error('Aborted');
544
+ }
545
+ }
546
+ }
527
547
  }
package/lib/session.js CHANGED
@@ -347,7 +347,8 @@ export default class Session {
347
347
  const { cli, upstream, branch } = this;
348
348
  const branchName = `${upstream}/${branch}`;
349
349
  cli.startSpinner(`Bringing ${branchName} up to date...`);
350
- await runAsync('git', ['fetch', upstream, branch]);
350
+ const maybeUnshallow = fs.existsSync('.git/shallow') ? ['--unshallow'] : [];
351
+ await runAsync('git', ['fetch', ...maybeUnshallow, upstream, branch]);
351
352
  cli.stopSpinner(`${branchName} is now up-to-date`);
352
353
  const stray = this.getStrayCommits(true);
353
354
  if (!stray.length) {
@@ -44,6 +44,9 @@ const fastFloatReplace = `/third_party/fast_float/src/*
44
44
  const highwayIgnore = `/third_party/highway/src/*
45
45
  !/third_party/highway/src/hwy`;
46
46
 
47
+ const dragonboxIgnore = `/third_party/dragonbox/src/*
48
+ !/third_party/dragonbox/src/include`;
49
+
47
50
  export const v8Deps = [
48
51
  {
49
52
  name: 'trace_event',
@@ -133,5 +136,14 @@ export const v8Deps = [
133
136
  repo: 'third_party/simdutf',
134
137
  gitignore: '!/third_party/simdutf',
135
138
  since: 134
136
- }
139
+ },
140
+ {
141
+ name: 'dragonbox',
142
+ repo: 'third_party/dragonbox/src',
143
+ gitignore: {
144
+ match: '/third_party/dragonbox/src',
145
+ replace: dragonboxIgnore
146
+ },
147
+ since: 138
148
+ },
137
149
  ];
@@ -46,6 +46,10 @@ export default class UpdateSecurityRelease extends SecurityRelease {
46
46
  }
47
47
  const vulnerabilitiesJSONPath = this.getVulnerabilitiesJSONPath();
48
48
  fs.writeFileSync(vulnerabilitiesJSONPath, JSON.stringify(content, null, 2));
49
+
50
+ const commitMessage = 'chore: git node security --sync';
51
+ commitAndPushVulnerabilitiesJSON([vulnerabilitiesJSONPath],
52
+ commitMessage, { cli: this.cli, repository: this.repository });
49
53
  this.cli.ok('Synced vulnerabilities.json with HackerOne');
50
54
  }
51
55
 
package/lib/wpt/index.js CHANGED
@@ -79,9 +79,13 @@ export class WPTUpdater {
79
79
  await removeDirectory(this.fixtures(this.path));
80
80
 
81
81
  this.cli.startSpinner('Pulling assets...');
82
- await Promise.all(assets.map(
83
- (asset) => this.pullTextFile(fixtures, asset.name)
84
- ));
82
+ // See https://github.com/nodejs/node-core-utils/issues/810
83
+ for (let i = 0; i < assets.length; i += 10) {
84
+ const chunk = assets.slice(i, i + 10);
85
+ await Promise.all(chunk.map(
86
+ (asset) => this.pullTextFile(fixtures, asset.name)
87
+ ));
88
+ }
85
89
  this.cli.stopSpinner(`Downloaded ${assets.length} assets.`);
86
90
 
87
91
  return assets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node-core/utils",
3
- "version": "5.12.2",
3
+ "version": "5.14.0",
4
4
  "description": "Utilities for Node.js core collaborators",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,40 +34,40 @@
34
34
  ],
35
35
  "license": "MIT",
36
36
  "dependencies": {
37
- "@inquirer/prompts": "^7.2.3",
37
+ "@inquirer/prompts": "^7.4.1",
38
38
  "@listr2/prompt-adapter-enquirer": "^2.0.12",
39
39
  "@node-core/caritat": "^1.6.0",
40
40
  "@pkgjs/nv": "^0.2.2",
41
41
  "branch-diff": "^3.1.1",
42
42
  "chalk": "^5.4.1",
43
- "changelog-maker": "^4.3.2",
43
+ "changelog-maker": "^4.4.1",
44
44
  "cheerio": "^1.0.0",
45
45
  "clipboardy": "^4.0.0",
46
46
  "core-validate-commit": "^4.1.0",
47
47
  "figures": "^6.1.0",
48
- "ghauth": "^6.0.10",
48
+ "ghauth": "^6.0.12",
49
49
  "git-secure-tag": "^2.3.1",
50
50
  "js-yaml": "^4.1.0",
51
51
  "listr2": "^8.2.5",
52
52
  "lodash": "^4.17.21",
53
53
  "log-symbols": "^7.0.0",
54
- "ora": "^8.1.1",
54
+ "ora": "^8.2.0",
55
55
  "replace-in-file": "^8.3.0",
56
- "semver": "^7.6.3",
57
- "undici": "^7.3.0",
56
+ "semver": "^7.7.1",
57
+ "undici": "^7.7.0",
58
58
  "which": "^5.0.0",
59
59
  "yargs": "^17.7.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@eslint/js": "^9.19.0",
62
+ "@eslint/js": "^9.24.0",
63
63
  "@reporters/github": "^1.7.2",
64
64
  "c8": "^10.1.3",
65
- "eslint": "^9.19.0",
65
+ "eslint": "^9.24.0",
66
66
  "eslint-plugin-import": "^2.31.0",
67
- "eslint-plugin-n": "^17.15.1",
67
+ "eslint-plugin-n": "^17.17.0",
68
68
  "eslint-plugin-promise": "^7.2.1",
69
- "globals": "^15.14.0",
70
- "neostandard": "^0.12.0",
71
- "sinon": "^19.0.2"
69
+ "globals": "^16.0.0",
70
+ "neostandard": "^0.12.1",
71
+ "sinon": "^20.0.0"
72
72
  }
73
73
  }