@node-core/utils 6.4.0 → 7.0.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.
Files changed (56) hide show
  1. package/README.md +2 -5
  2. package/bin/ncu-ci.js +3 -19
  3. package/components/git/benchmark.js +112 -0
  4. package/components/git/land.js +3 -3
  5. package/components/git/metadata.js +34 -1
  6. package/components/git/release.js +3 -5
  7. package/components/git/security.js +258 -27
  8. package/components/git/v8.js +6 -3
  9. package/components/metadata.js +75 -7
  10. package/lib/auth.js +3 -4
  11. package/lib/backport_session.js +3 -3
  12. package/lib/benchmark.js +243 -0
  13. package/lib/cherry_pick.js +32 -11
  14. package/lib/ci/failure_aggregator.js +16 -20
  15. package/lib/ci/run_ci.js +1 -1
  16. package/lib/cli.js +25 -1
  17. package/lib/config.js +7 -7
  18. package/lib/file.js +5 -5
  19. package/lib/landing_session.js +65 -24
  20. package/lib/links.js +4 -4
  21. package/lib/pr_checker.js +163 -31
  22. package/lib/pr_data.js +1 -1
  23. package/lib/pr_summary.js +1 -1
  24. package/lib/prepare_release.js +270 -85
  25. package/lib/prepare_security.js +312 -46
  26. package/lib/promote_release.js +19 -16
  27. package/lib/queries/ListSecurityReleasePRs.gql +19 -0
  28. package/lib/queries/PR.gql +4 -1
  29. package/lib/request.js +41 -0
  30. package/lib/run.js +5 -5
  31. package/lib/security-announcement.js +10 -5
  32. package/lib/security-release/security-release.js +160 -20
  33. package/lib/security_blog.js +130 -65
  34. package/lib/session.js +4 -3
  35. package/lib/team_info.js +1 -1
  36. package/lib/update-v8/applyNodeChanges.js +1 -3
  37. package/lib/update-v8/backport.js +6 -6
  38. package/lib/update-v8/commitUpdate.js +3 -6
  39. package/lib/update-v8/common.js +30 -6
  40. package/lib/update-v8/constants.js +5 -0
  41. package/lib/update-v8/deps.js +3 -5
  42. package/lib/update-v8/index.js +14 -5
  43. package/lib/update-v8/majorUpdate.js +5 -4
  44. package/lib/update-v8/minorUpdate.js +4 -4
  45. package/lib/update-v8/updateCrates.js +192 -0
  46. package/lib/update-v8/updateV8Clone.js +1 -1
  47. package/lib/update-v8/updateVersionNumbers.js +3 -6
  48. package/lib/update-v8/util.js +3 -3
  49. package/lib/update_security_release.js +347 -105
  50. package/lib/utils.js +5 -5
  51. package/lib/verbosity.js +3 -3
  52. package/lib/wpt/index.js +1 -23
  53. package/package.json +18 -22
  54. package/bin/get-metadata.js +0 -11
  55. package/lib/wpt/templates/README.md +0 -16
  56. package/npm-shrinkwrap.json +0 -20380
@@ -2,12 +2,9 @@ import path from 'node:path';
2
2
  import { promises as fs, existsSync, readFileSync } from 'node:fs';
3
3
 
4
4
  import semver from 'semver';
5
- import { replaceInFile } from 'replace-in-file';
6
5
 
7
6
  import { forceRunAsync, runAsync, runSync } from './run.js';
8
7
  import { writeJson, readJson } from './file.js';
9
- import Request from './request.js';
10
- import auth from './auth.js';
11
8
  import {
12
9
  getEOLDate,
13
10
  getStartLTSBlurb,
@@ -15,9 +12,73 @@ import {
15
12
  } from './release/utils.js';
16
13
  import CherryPick from './cherry_pick.js';
17
14
  import Session from './session.js';
15
+ import {
16
+ getAffectedVersionLines,
17
+ getDependencyUpdates,
18
+ SEVERITY_RANKS
19
+ } from './security-release/security-release.js';
18
20
 
19
21
  const isWindows = process.platform === 'win32';
20
22
 
23
+ // Parse a GitHub Pull Request URL into its owner, repo and number.
24
+ // Returns null when the URL does not look like a PR URL.
25
+ export function parsePullRequestURL(url) {
26
+ const match = /github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/.exec(url || '');
27
+ if (!match) return null;
28
+ return { owner: match[1], repo: match[2], number: Number(match[3]) };
29
+ }
30
+
31
+ // Resolve the PR URL that fixes a given release line from a report or
32
+ // dependency `affectedVersions` entry.
33
+ // Supports the current schema, where `affectedVersions` maps a release line to
34
+ // its PR URL (e.g. `{ '22.x': 'https://.../pull/1' }`), and the legacy schema,
35
+ // where `affectedVersions` is an array of lines paired with a single `prURL`.
36
+ export function getPullRequestURLForLine(affectedVersions, line, legacyPrURL) {
37
+ if (
38
+ affectedVersions &&
39
+ typeof affectedVersions === 'object' &&
40
+ !Array.isArray(affectedVersions)
41
+ ) {
42
+ return affectedVersions[line] || null;
43
+ }
44
+
45
+ if (Array.isArray(affectedVersions) && legacyPrURL) {
46
+ return getAffectedVersionLines(affectedVersions).includes(line)
47
+ ? legacyPrURL
48
+ : null;
49
+ }
50
+
51
+ return null;
52
+ }
53
+
54
+ // Format the notable changes of a security release, one entry per commit:
55
+ // `* (CVE-ID) subsystem: title (Author) – Severity`, sorted from highest to
56
+ // lowest severity, then by CVE-ID. Commits without a CVE-ID trailer
57
+ // (e.g. dependency updates) are listed last.
58
+ export function formatSecurityNotableChanges(commits, severityByCVE) {
59
+ const rank = rating => SEVERITY_RANKS.indexOf((rating || '').toUpperCase());
60
+ const entries = commits
61
+ .filter(({ subject }) => subject)
62
+ .map(({ subject, author, cveIds = [] }) => ({
63
+ subject,
64
+ author,
65
+ cveIds,
66
+ rating: cveIds.map(id => severityByCVE.get(id)).find(Boolean) || ''
67
+ }))
68
+ .sort((a, b) => (rank(b.rating) - rank(a.rating)) ||
69
+ (b.cveIds.length - a.cveIds.length) ||
70
+ (a.cveIds[0] || '').localeCompare(b.cveIds[0] || '', 'en', { numeric: true }));
71
+
72
+ const lines = entries.map(({ subject, author, cveIds, rating }) => {
73
+ const cve = cveIds.length ? `(${cveIds.join(', ')}) ` : '';
74
+ const severity = rating
75
+ ? ` – ${rating[0].toUpperCase()}${rating.slice(1).toLowerCase()}`
76
+ : '';
77
+ return `* ${cve}${subject} (${author})${severity}`;
78
+ });
79
+ return lines.length ? `${lines.join('\n')}\n` : '';
80
+ }
81
+
21
82
  export default class ReleasePreparation extends Session {
22
83
  constructor(argv, cli, dir) {
23
84
  super(cli, dir);
@@ -32,101 +93,162 @@ export default class ReleasePreparation extends Session {
32
93
  this.defaultReleaseDate = argv.releaseDate ?? new Date().toISOString().slice(0, 10);
33
94
  this.ltsCodename = '';
34
95
  this.date = '';
35
- this.filterLabels = argv.filterLabel && argv.filterLabel.split(',');
36
96
  this.newVersion = argv.newVersion;
37
97
  }
38
98
 
39
99
  get branch() {
40
- return this.stagingBranch;
100
+ return this.isSecurityRelease ? this.releaseBranch : this.stagingBranch;
101
+ }
102
+
103
+ // Resolve the path to the security release vulnerabilities.json.
104
+ // `--security` may point either directly at a vulnerabilities.json file or at
105
+ // the root of the security-release repository, in which case the well-known
106
+ // sub-path is appended.
107
+ getVulnerabilitiesJSONPath() {
108
+ if (!this.securityReleaseRepo) return null;
109
+ if (this.securityReleaseRepo.endsWith('.json')) {
110
+ return this.securityReleaseRepo;
111
+ }
112
+ return path.join(
113
+ this.securityReleaseRepo,
114
+ 'security-release',
115
+ 'next-security-release',
116
+ 'vulnerabilities.json'
117
+ );
41
118
  }
42
119
 
43
- warnForNonMergeablePR(pr) {
120
+ loadVulnerabilitiesJSON() {
44
121
  const { cli } = this;
122
+ const vulnPath = this.getVulnerabilitiesJSONPath();
123
+
124
+ if (!vulnPath) {
125
+ cli.error('No vulnerabilities.json path provided. Pass the path to ' +
126
+ 'vulnerabilities.json (or the security-release repository) via ' +
127
+ '`--security=<path>`.');
128
+ return null;
129
+ }
45
130
 
46
- cli.warn(`PR#${pr.number} - ${pr.title} is not 'MERGEABLE'.
47
- Status: ${pr.mergeable}`);
131
+ if (!existsSync(vulnPath)) {
132
+ cli.error(`vulnerabilities.json not found at ${vulnPath}.`);
133
+ return null;
134
+ }
135
+
136
+ try {
137
+ cli.startSpinner(`Reading vulnerabilities.json from ${vulnPath}..`);
138
+ const vulnData = JSON.parse(readFileSync(vulnPath, 'utf-8'));
139
+ cli.stopSpinner(`Done reading vulnerabilities.json from ${vulnPath}`);
140
+ return vulnData;
141
+ } catch (err) {
142
+ cli.error(`Failed to read vulnerabilities.json: ${err.message}`);
143
+ return null;
144
+ }
48
145
  }
49
146
 
50
- async getOpenPRs(filterLabels) {
51
- const credentials = await auth({ github: true });
52
- const request = new Request(credentials);
53
- const data = await request.gql('PRs', {
54
- owner: this.owner,
55
- repo: this.repo,
56
- labels: filterLabels
57
- });
58
- return data.repository.pullRequests.nodes;
147
+ // Build the list of PRs to cherry-pick for a given release line from both the
148
+ // reports and the dependency updates, using their `affectedVersions` mapping.
149
+ getSecurityCherryPickTargets(vulnData, line) {
150
+ const targets = [];
151
+
152
+ for (const report of vulnData.reports ?? []) {
153
+ const url = getPullRequestURLForLine(
154
+ report.affectedVersions, line, report.prURL);
155
+ if (url) {
156
+ targets.push({
157
+ url,
158
+ cveIds: report.cveIds?.length ? report.cveIds : null,
159
+ label: report.title || report.id
160
+ });
161
+ }
162
+ }
163
+
164
+ for (const dep of getDependencyUpdates(vulnData.dependencies)) {
165
+ const url = getPullRequestURLForLine(
166
+ dep.affectedVersions, line, dep.prURL);
167
+ if (url) {
168
+ targets.push({
169
+ url,
170
+ cveIds: null,
171
+ label: `dependency: ${dep.name}`,
172
+ isDependency: true
173
+ });
174
+ }
175
+ }
176
+
177
+ return targets;
59
178
  }
60
179
 
61
- async cherryPickSecurityPRs(filterLabels) {
180
+ upstreamForPR({ owner, repo }) {
181
+ if (owner === 'nodejs-private') {
182
+ return `https://${this.username}:${this.config.token}` +
183
+ `@github.com/${owner}/${repo}.git`;
184
+ }
185
+ return `https://github.com/${owner}/${repo}.git`;
186
+ }
187
+
188
+ async cherryPickSecurityPRs() {
62
189
  const { cli } = this;
190
+ const line = `${this.versionComponents.major}.x`;
63
191
 
64
- const prs = await this.getOpenPRs(filterLabels);
65
- if (prs.length === 0) {
66
- cli.warn(`There are no PRs available in ${this.owner}/${this.repo}`);
192
+ const vulnData = this.loadVulnerabilitiesJSON();
193
+ if (!vulnData) {
194
+ cli.error('Cannot proceed with a security release without a valid ' +
195
+ 'vulnerabilities.json.');
67
196
  return false;
68
197
  }
69
198
 
70
- const vulnCveMap = new Map();
71
- if (this.isSecurityRelease && this.securityReleaseRepo) {
72
- const vulnPath = path.join(
73
- this.securityReleaseRepo,
74
- 'security-release',
75
- 'next-security-release',
76
- 'vulnerabilities.json'
77
- );
78
-
79
- if (!existsSync(vulnPath)) {
80
- cli.error(`vulnerabilities.json not found at ${vulnPath}. ` +
81
- 'Skipping CVE auto-population.');
82
- cli.warn('PRs will require manual CVE-ID entry.');
83
- } else {
84
- try {
85
- cli.startSpinner(`Reading vulnerabilities.json from ${vulnPath}..`);
86
- const vulnData = JSON.parse(readFileSync(vulnPath, 'utf-8'));
87
- cli.stopSpinner(`Done reading vulnerabilities.json from ${vulnPath}`);
88
-
89
- if (vulnData.reports && Array.isArray(vulnData.reports)) {
90
- vulnData.reports.forEach(report => {
91
- if (report.prURL && report.cveIds && report.cveIds.length > 0) {
92
- vulnCveMap.set(report.prURL, report.cveIds);
93
- }
94
- });
95
- }
96
- cli.ok(`Loaded ${vulnCveMap.size} CVE mappings from vulnerabilities.json`);
97
- } catch (err) {
98
- cli.error(`Failed to read vulnerabilities.json: ${err.message}`);
99
- cli.warn('Continuing without CVE auto-population.');
100
- }
101
- }
199
+ const targets = this.getSecurityCherryPickTargets(vulnData, line);
200
+ if (targets.length === 0) {
201
+ cli.warn(`No PRs found in vulnerabilities.json for release line ${line}.`);
202
+ return false;
102
203
  }
103
204
 
104
- for (const pr of prs) {
105
- if (pr.mergeable !== 'MERGEABLE') {
106
- this.warnForNonMergeablePR(pr);
205
+ cli.info(`Cherry-picking ${targets.length} PR(s) for ${line} from ` +
206
+ 'vulnerabilities.json');
207
+
208
+ let amendAll = false;
209
+
210
+ for (const target of targets) {
211
+ const pr = parsePullRequestURL(target.url);
212
+ if (!pr) {
213
+ cli.warn(`Skipping invalid PR URL for ${target.label}: ${target.url}`);
214
+ continue;
107
215
  }
108
216
 
109
- // Look up CVE-IDs from vulnerabilities.json
110
- const prUrl = `https://github.com/${this.owner}/${this.repo}/pull/${pr.number}`;
111
- const cveIds = vulnCveMap.get(prUrl);
217
+ if (!amendAll) {
218
+ const answer = await cli.promptSelect(
219
+ `Would you like to amend ${target.label} (${target.url}) to the proposal?`,
220
+ [
221
+ { name: 'Yes', value: 'yes' },
222
+ { name: 'No, skip this PR', value: 'no' },
223
+ {
224
+ name: 'Yes to all remaining PRs (no further prompts)',
225
+ value: 'all'
226
+ }
227
+ ]);
228
+
229
+ if (answer === 'no') continue;
230
+ amendAll = answer === 'all';
231
+ }
112
232
 
113
- if (!cveIds || cveIds.length === 0) {
114
- cli.warn(`No CVE-IDs found in vulnerabilities.json for ${prUrl}`);
233
+ if (!target.cveIds && !target.isDependency) {
234
+ cli.warn(`No CVE-IDs found in vulnerabilities.json for ${target.url}`);
115
235
  }
116
236
 
237
+ cli.info(`Cherry-picking ${target.label} (${target.url})`);
117
238
  const cp = new CherryPick(pr.number, this.dir, cli, {
118
- owner: this.owner,
119
- repo: this.repo,
239
+ owner: pr.owner,
240
+ repo: pr.repo,
120
241
  gpgSign: this.gpgSign,
121
- upstream: this.isSecurityRelease ? `https://${this.username}:${this.config.token}@github.com/${this.owner}/${this.repo}.git` : this.upstream,
242
+ upstream: this.upstreamForPR(pr),
122
243
  lint: false,
123
- includeCVE: true,
124
- cveIds: cveIds || null,
125
- vulnCveMap
244
+ includeCVE: !target.isDependency,
245
+ cveIds: target.cveIds,
246
+ promptAmend: false,
247
+ skipMessagePrompt: amendAll
126
248
  });
127
249
  const success = await cp.start();
128
250
  if (!success) {
129
- cli.warn('The cherry-pick has failed. PR-ID: ' + pr.number);
251
+ cli.warn(`The cherry-pick has failed. PR: ${target.url}`);
130
252
  return false;
131
253
  }
132
254
  }
@@ -147,7 +269,7 @@ export default class ReleasePreparation extends Session {
147
269
  { defaultAnswer: this.runBranchDiff });
148
270
  if (runBranchDiff) {
149
271
  if (isSecurityRelease) {
150
- const success = await this.cherryPickSecurityPRs(this.filterLabels);
272
+ const success = await this.cherryPickSecurityPRs();
151
273
  if (!success) {
152
274
  cli.error('Aborting security release preparation.');
153
275
  return;
@@ -395,11 +517,24 @@ export default class ReleasePreparation extends Session {
395
517
  async updateREPLACEMEs() {
396
518
  const { newVersion } = this;
397
519
 
398
- await replaceInFile({
399
- files: 'doc/api/*.md',
400
- from: /REPLACEME/g,
401
- to: `v${newVersion}`
402
- });
520
+ const promises = [];
521
+ for await (const file of fs.glob('doc/api/*.md')) {
522
+ promises.push(fs.open(file, 'r+').then(async fd => {
523
+ try {
524
+ const fileContent = await fd.readFile('utf-8');
525
+ if (fileContent.includes('REPLACEME')) {
526
+ const { bytesWritten } =
527
+ await fd.write(fileContent.replaceAll('REPLACEME', `v${newVersion}`), 0, 'utf-8');
528
+ await fd.truncate(bytesWritten);
529
+ }
530
+ // eslint-disable-next-line promise/always-return
531
+ } finally {
532
+ // TODO: replace with `using` syntax when dropping support for Node.js 22
533
+ await fd.close();
534
+ }
535
+ }));
536
+ }
537
+ await Promise.all(promises);
403
538
  }
404
539
 
405
540
  async updateMainChangelog() {
@@ -436,7 +571,7 @@ export default class ReleasePreparation extends Session {
436
571
  arr.splice(idx, 1, `<b>${newRefLink}</b><br/>`, `${lastRefLink}<br/>`);
437
572
  break;
438
573
  }
439
- };
574
+ }
440
575
 
441
576
  await fs.writeFile(mainChangelogPath, arr.join('\n'));
442
577
  }
@@ -464,10 +599,12 @@ export default class ReleasePreparation extends Session {
464
599
  const data = await fs.readFile(majorChangelogPath, 'utf8');
465
600
  const arr = data.split('\n');
466
601
  const allCommits = this.getChangelog();
467
- const notableChanges = await this.getBranchDiff({
468
- onlyNotableChanges: true,
469
- format: isSecurityRelease ? 'messageonly' : 'markdown',
470
- });
602
+ const notableChanges = isSecurityRelease
603
+ ? await this.getSecurityNotableChanges()
604
+ : await this.getBranchDiff({
605
+ onlyNotableChanges: true,
606
+ format: 'markdown',
607
+ });
471
608
  let releaseHeader = `## ${date}, Version ${newVersion}` +
472
609
  ` ${releaseInfo}, @${username}\n`;
473
610
  if (isSecurityRelease) {
@@ -515,12 +652,12 @@ export default class ReleasePreparation extends Session {
515
652
  arr.splice(idx, 0, ...toAppend);
516
653
  break;
517
654
  }
518
- };
655
+ }
519
656
 
520
657
  await fs.writeFile(majorChangelogPath, arr.join('\n'));
521
658
  }
522
659
 
523
- async createProposalBranch(base = this.stagingBranch) {
660
+ async createProposalBranch(base = this.branch) {
524
661
  const { newVersion } = this;
525
662
  const proposalBranch = `v${newVersion}-proposal`;
526
663
 
@@ -603,10 +740,12 @@ export default class ReleasePreparation extends Session {
603
740
  messageBody.push('This is a security release.\n\n');
604
741
  }
605
742
 
606
- const notableChanges = await this.getBranchDiff({
607
- onlyNotableChanges: true,
608
- format: isSecurityRelease ? 'messageonly' : 'plaintext'
609
- });
743
+ const notableChanges = isSecurityRelease
744
+ ? await this.getSecurityNotableChanges()
745
+ : await this.getBranchDiff({
746
+ onlyNotableChanges: true,
747
+ format: 'plaintext'
748
+ });
610
749
  messageBody.push('Notable changes:\n\n');
611
750
  if (isLTSTransition) {
612
751
  messageBody.push(`${getStartLTSBlurb(this)}\n\n`);
@@ -629,6 +768,48 @@ export default class ReleasePreparation extends Session {
629
768
  return useMessage;
630
769
  }
631
770
 
771
+ // Build the notable changes of a security release from the commits
772
+ // cherry-picked onto the proposal branch and the severity ratings in
773
+ // vulnerabilities.json.
774
+ async getSecurityNotableChanges() {
775
+ const { upstream, versionComponents } = this;
776
+ const releaseBranch = `v${versionComponents.major}.x`;
777
+
778
+ await forceRunAsync('git', ['remote', 'set-branches', '--add', upstream, releaseBranch], {
779
+ ignoreFailures: false
780
+ });
781
+ await forceRunAsync('git', ['fetch', upstream, releaseBranch], { ignoreFailures: false });
782
+
783
+ const severityByCVE = new Map();
784
+ const vulnPath = this.getVulnerabilitiesJSONPath();
785
+ if (vulnPath && existsSync(vulnPath)) {
786
+ const { reports } = JSON.parse(readFileSync(vulnPath, 'utf-8'));
787
+ for (const report of reports ?? []) {
788
+ for (const cveId of report.cveIds ?? []) {
789
+ severityByCVE.set(cveId, report.severity?.rating);
790
+ }
791
+ }
792
+ }
793
+
794
+ const log = runSync('git', [
795
+ 'log',
796
+ '--format=%s%x1f%an%x1f%(trailers:key=CVE-ID,valueonly,separator=%x2C)%x1e',
797
+ `${upstream}/${releaseBranch}..HEAD`
798
+ ]);
799
+ const commits = log.split('\x1e').map(record => {
800
+ const [subject, author, cveIds] = record.trim().split('\x1f');
801
+ return {
802
+ subject,
803
+ author,
804
+ // Ignore placeholder trailers such as CVE-2026-XXXXX.
805
+ cveIds: (cveIds || '').split(',')
806
+ .map(id => id.trim())
807
+ .filter(id => /^CVE-\d{4}-\d+$/.test(id))
808
+ };
809
+ });
810
+ return formatSecurityNotableChanges(commits, severityByCVE);
811
+ }
812
+
632
813
  async getBranchDiff(opts) {
633
814
  const {
634
815
  cli,
@@ -796,6 +977,10 @@ export default class ReleasePreparation extends Session {
796
977
  return;
797
978
  }
798
979
  this.stagingBranch = currentBranch;
980
+ // On security releases `branch` resolves to `releaseBranch`, which
981
+ // tryResetBranch() needs. Derive it from the branch that is checked out:
982
+ // the new version is only calculated below, and it may bump the major.
983
+ this.releaseBranch = `v${match[1]}.x`;
799
984
  await this.tryResetBranch();
800
985
  this.versionComponents = await this.calculateNewVersion(await this.getLastRelease(match[1]));
801
986
  const { major, minor, patch } = this.versionComponents;