@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
@@ -11,10 +11,12 @@ import {
11
11
  promptDependencies,
12
12
  getSupportedVersions,
13
13
  getReportSeverity,
14
+ getSummary,
14
15
  pickReport,
16
+ confirmSecurityStep,
17
+ writeSecurityFile,
15
18
  SecurityRelease
16
19
  } from './security-release/security-release.js';
17
- import _ from 'lodash';
18
20
 
19
21
  function relativeDate(date) {
20
22
  const days = Math.floor((Date.now() - date) / (1000 * 60 * 60 * 24));
@@ -23,6 +25,131 @@ function relativeDate(date) {
23
25
  return months === 1 ? '1 month ago' : `${months} months ago`;
24
26
  }
25
27
 
28
+ function formatDate(date) {
29
+ const year = date.getFullYear();
30
+ const month = String(date.getMonth() + 1).padStart(2, '0');
31
+ const day = String(date.getDate()).padStart(2, '0');
32
+ return `${year}/${month}/${day}`;
33
+ }
34
+
35
+ function formatDaysFromNow(days) {
36
+ return days === 1 ? 'in 1 day' : `in ${days} days`;
37
+ }
38
+
39
+ function getNextTuesdayReleaseDateObjects(fromDate = new Date(), count = 4) {
40
+ const TUESDAY = 2;
41
+ const start = new Date(
42
+ fromDate.getFullYear(),
43
+ fromDate.getMonth(),
44
+ fromDate.getDate()
45
+ );
46
+ const daysUntilTuesday = (TUESDAY - start.getDay() + 7) % 7 || 7;
47
+ start.setDate(start.getDate() + daysUntilTuesday);
48
+
49
+ return Array.from({ length: count }, (_, index) => {
50
+ const date = new Date(start);
51
+ date.setDate(start.getDate() + (index * 7));
52
+ return {
53
+ date,
54
+ daysFromNow: daysUntilTuesday + (index * 7)
55
+ };
56
+ });
57
+ }
58
+
59
+ export function getNextTuesdayReleaseDates(fromDate = new Date(), count = 4) {
60
+ return getNextTuesdayReleaseDateObjects(fromDate, count)
61
+ .map(({ date }) => formatDate(date));
62
+ }
63
+
64
+ export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count = 4) {
65
+ const choices = getNextTuesdayReleaseDateObjects(fromDate, count)
66
+ .map(({ date, daysFromNow }) => {
67
+ const formattedDate = formatDate(date);
68
+ return {
69
+ name: formattedDate,
70
+ value: formattedDate,
71
+ description: `Tuesday, ${formatDaysFromNow(daysFromNow)}`
72
+ };
73
+ });
74
+
75
+ choices.push({
76
+ name: 'TBD',
77
+ value: 'TBD',
78
+ description: 'Release date not defined yet'
79
+ });
80
+
81
+ return choices;
82
+ }
83
+
84
+ function getReportPRURL(report) {
85
+ const customFieldValues = report.relationships.custom_field_values?.data ?? [];
86
+ return customFieldValues[0]?.attributes?.value ?? '';
87
+ }
88
+
89
+ export function buildIncludedTriagedReport(report, options = {}) {
90
+ const {
91
+ affectedVersions = '',
92
+ patchAuthors = [],
93
+ prURL = getReportPRURL(report)
94
+ } = options;
95
+ const {
96
+ id,
97
+ attributes: { title, cve_ids = [] },
98
+ relationships: { reporter }
99
+ } = report;
100
+ const link = `https://hackerone.com/reports/${id}`;
101
+ const summaryContent = getSummary(report);
102
+
103
+ return {
104
+ id,
105
+ title,
106
+ cveIds: cve_ids,
107
+ severity: getReportSeverity(report),
108
+ summary: summaryContent ?? '',
109
+ patchAuthors,
110
+ prURL,
111
+ affectedVersions: affectedVersions
112
+ .split(',')
113
+ .map((v) => v.replace('v', '').trim())
114
+ .filter(Boolean),
115
+ link,
116
+ reporter: reporter?.data?.attributes?.username ?? ''
117
+ };
118
+ }
119
+
120
+ export function getMissingReportInformation(report) {
121
+ const missing = [];
122
+
123
+ if (!report.severity?.rating) missing.push('severity rating');
124
+ if (!report.severity?.cvss_vector_string) missing.push('CVSS vector');
125
+ if (!report.severity?.weakness_id) missing.push('weakness ID');
126
+ if (!report.summary) missing.push('team summary');
127
+ if (!report.prURL) missing.push('PR URL');
128
+ if (!report.patchAuthors?.length) missing.push('patch authors');
129
+ if (!report.affectedVersions?.length) missing.push('affected versions');
130
+
131
+ return missing;
132
+ }
133
+
134
+ export function groupMissingReportInformation(reports) {
135
+ const grouped = new Map();
136
+
137
+ for (const report of reports) {
138
+ for (const field of report.missing) {
139
+ const current = grouped.get(field) ?? [];
140
+ current.push(report);
141
+ grouped.set(field, current);
142
+ }
143
+ }
144
+
145
+ return Array.from(grouped.entries())
146
+ .map(([field, fieldReports]) => ({
147
+ field,
148
+ reports: fieldReports
149
+ }))
150
+ .sort((a, b) => b.reports.length - a.reports.length);
151
+ }
152
+
26
153
  export default class PrepareSecurityRelease extends SecurityRelease {
27
154
  title = 'Next Security Release';
28
155
 
@@ -35,22 +162,23 @@ export default class PrepareSecurityRelease extends SecurityRelease {
35
162
  this.req = new Request(credentials);
36
163
 
37
164
  let excludedReports = [];
38
- const showTriaged = await this.promptShowTriagedWithoutPR();
39
- if (showTriaged) {
40
- excludedReports = await this.showTriagedReportsWithoutPR();
41
- }
42
-
43
165
  const releaseDate = await this.promptReleaseDate();
44
166
  if (releaseDate !== 'TBD') {
45
167
  validateDate(releaseDate);
46
168
  }
47
169
  const createVulnerabilitiesJSON = await this.promptVulnerabilitiesJSON();
48
170
 
49
- let securityReleasePRUrl;
50
- const content = await this.buildDescription(releaseDate, securityReleasePRUrl);
171
+ const content = await this.buildDescription(releaseDate);
51
172
  if (createVulnerabilitiesJSON) {
52
- securityReleasePRUrl = await this.startVulnerabilitiesJSONCreation(
53
- releaseDate, content, excludedReports);
173
+ const reportSelectionMode = await this.promptReportSelectionMode();
174
+ if (reportSelectionMode === 'review') {
175
+ const showTriaged = await this.promptShowTriagedWithoutPR();
176
+ if (showTriaged) {
177
+ excludedReports = await this.showTriagedReportsWithoutPR();
178
+ }
179
+ }
180
+ await this.startVulnerabilitiesJSONCreation(
181
+ releaseDate, content, excludedReports, reportSelectionMode);
54
182
  }
55
183
 
56
184
  this.cli.ok('Done!');
@@ -73,6 +201,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
73
201
 
74
202
  if (vulnerabilityJSON.buildIssue) {
75
203
  this.cli.info('Commenting on nodejs/build issue');
204
+ await confirmSecurityStep(
205
+ this.cli,
206
+ `comment on GitHub issue \`${vulnerabilityJSON.buildIssue}\``,
207
+ 'This posts that the security release is out on the build tracking issue.'
208
+ );
76
209
  await this.req.commentIssue(
77
210
  vulnerabilityJSON.buildIssue,
78
211
  'Security release is out'
@@ -81,6 +214,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
81
214
 
82
215
  if (vulnerabilityJSON.dockerIssue) {
83
216
  this.cli.info('Commenting on nodejs/docker-node issue');
217
+ await confirmSecurityStep(
218
+ this.cli,
219
+ `comment on GitHub issue \`${vulnerabilityJSON.dockerIssue}\``,
220
+ 'This posts that the security release is out on the docker-node tracking issue.'
221
+ );
84
222
  await this.req.commentIssue(
85
223
  vulnerabilityJSON.dockerIssue,
86
224
  'Security release is out'
@@ -92,11 +230,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
92
230
  vulnerabilityJSON.releaseDate}?`,
93
231
  { defaultAnswer: true });
94
232
  if (updateFolder) {
95
- this.updateReleaseFolder(
233
+ await this.updateReleaseFolder(
96
234
  vulnerabilityJSON.releaseDate.replaceAll('/', '-')
97
235
  );
98
236
  const securityReleaseFolder = path.join(process.cwd(), 'security-release');
99
- commitAndPushVulnerabilitiesJSON(
237
+ await commitAndPushVulnerabilitiesJSON(
100
238
  securityReleaseFolder,
101
239
  'chore: change next-security-release folder',
102
240
  { cli: this.cli, repository: this.repository }
@@ -109,14 +247,20 @@ export default class PrepareSecurityRelease extends SecurityRelease {
109
247
  this.cli.ok('Done!');
110
248
  }
111
249
 
112
- async startVulnerabilitiesJSONCreation(releaseDate, content, excludedReports = []) {
250
+ async startVulnerabilitiesJSONCreation(
251
+ releaseDate,
252
+ content,
253
+ excludedReports = [],
254
+ reportSelectionMode = 'review'
255
+ ) {
113
256
  // checkout on the next-security-release branch
114
- checkoutOnSecurityReleaseBranch(this.cli, this.repository);
257
+ await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
115
258
 
116
259
  // choose the reports to include in the security release
117
- const reports = await this.chooseReports(excludedReports);
118
- const depUpdates = await this.getDependencyUpdates();
119
- const deps = _.groupBy(depUpdates, 'name');
260
+ const reports = reportSelectionMode === 'include-all'
261
+ ? await this.includeAllTriagedReports(excludedReports)
262
+ : await this.chooseReports(excludedReports);
263
+ const deps = await this.getDependencyUpdates();
120
264
 
121
265
  // create the vulnerabilities.json file in the security-release repo
122
266
  const filePath = await this.createVulnerabilitiesJSON(reports, deps, releaseDate);
@@ -131,11 +275,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
131
275
  - git push -u origin ${NEXT_SECURITY_RELEASE_BRANCH}
132
276
  - open a PR on ${this.repository.owner}/${this.repository.repo}`);
133
277
  return;
134
- };
278
+ }
135
279
 
136
280
  // commit and push the vulnerabilities.json file
137
281
  const commitMessage = 'chore: create vulnerabilities.json for next security release';
138
- commitAndPushVulnerabilitiesJSON(filePath,
282
+ await commitAndPushVulnerabilitiesJSON(filePath,
139
283
  commitMessage,
140
284
  { cli: this.cli, repository: this.repository });
141
285
 
@@ -165,21 +309,18 @@ export default class PrepareSecurityRelease extends SecurityRelease {
165
309
  return body.substring(index);
166
310
  }
167
311
  return body;
168
- } catch (error) {
312
+ } catch {
169
313
  this.cli.error(`Could not retrieve the security issue template from ${url}`);
170
314
  }
171
315
  }
172
316
 
173
317
  async promptReleaseDate() {
174
- const nextWeekDate = new Date();
175
- nextWeekDate.setDate(nextWeekDate.getDate() + 7);
176
- // Format the date as YYYY/MM/DD
177
- const formattedDate = nextWeekDate.toISOString().slice(0, 10).replace(/-/g, '/');
178
- return this.cli.prompt(
179
- 'Enter target release date in YYYY/MM/DD format (TBD if not defined yet):', {
180
- questionType: 'input',
181
- defaultAnswer: formattedDate
182
- });
318
+ const choices = getNextTuesdayReleaseDateChoices();
319
+ return this.cli.promptSelect(
320
+ 'Select target release date:',
321
+ choices,
322
+ { defaultAnswer: choices[0].value }
323
+ );
183
324
  }
184
325
 
185
326
  async promptVulnerabilitiesJSON() {
@@ -188,6 +329,25 @@ export default class PrepareSecurityRelease extends SecurityRelease {
188
329
  { defaultAnswer: true });
189
330
  }
190
331
 
332
+ async promptReportSelectionMode() {
333
+ return this.cli.promptSelect(
334
+ 'How would you like to choose reports for the next security release?',
335
+ [
336
+ {
337
+ name: 'Review each triaged report',
338
+ value: 'review',
339
+ description: 'Iterate over reports and fill missing details interactively'
340
+ },
341
+ {
342
+ name: 'Include all triaged reports',
343
+ value: 'include-all',
344
+ description: 'Add every triaged report and summarize missing information'
345
+ }
346
+ ],
347
+ { defaultAnswer: 'review' }
348
+ );
349
+ }
350
+
191
351
  async promptCreateRelaseIssue() {
192
352
  return this.cli.prompt(
193
353
  'Create the Next Security Release issue?',
@@ -262,6 +422,80 @@ export default class PrepareSecurityRelease extends SecurityRelease {
262
422
  return selectedReports;
263
423
  }
264
424
 
425
+ async includeAllTriagedReports(excludedReports = []) {
426
+ this.cli.info('Getting triaged H1 reports...');
427
+ const reports = await this.req.getTriagedReports();
428
+ const supportedVersions = await getSupportedVersions();
429
+ const selectedReports = [];
430
+ const missingInformation = [];
431
+
432
+ for (const report of reports.data) {
433
+ if (excludedReports.includes(report.id)) continue;
434
+
435
+ const reportData = await this.buildIncludedTriagedReport(
436
+ report,
437
+ supportedVersions
438
+ );
439
+ selectedReports.push(reportData);
440
+
441
+ const missing = getMissingReportInformation(reportData);
442
+ if (missing.length) {
443
+ missingInformation.push({
444
+ id: reportData.id,
445
+ title: reportData.title,
446
+ link: reportData.link,
447
+ missing
448
+ });
449
+ }
450
+ }
451
+
452
+ this.displayMissingReportInformationSummary(missingInformation);
453
+ return selectedReports;
454
+ }
455
+
456
+ async buildIncludedTriagedReport(report, supportedVersions) {
457
+ const prURL = getReportPRURL(report);
458
+ const patchAuthors = await this.getPatchAuthorsFromPR(prURL);
459
+ return buildIncludedTriagedReport(report, {
460
+ affectedVersions: supportedVersions,
461
+ patchAuthors,
462
+ prURL
463
+ });
464
+ }
465
+
466
+ async getPatchAuthorsFromPR(prURL) {
467
+ if (!prURL) return [];
468
+
469
+ try {
470
+ const { user } = await this.req.getPullRequest(prURL);
471
+ return user?.login ? [user.login] : [];
472
+ } catch (error) {
473
+ this.cli.warn(`Could not fetch patch author from ${prURL}`);
474
+ this.cli.error(error);
475
+ return [];
476
+ }
477
+ }
478
+
479
+ displayMissingReportInformationSummary(reports) {
480
+ if (!reports.length) {
481
+ this.cli.ok('All included reports have the expected information.');
482
+ return;
483
+ }
484
+
485
+ const grouped = groupMissingReportInformation(reports);
486
+ this.cli.warn(
487
+ `${reports.length} included reports are missing information:`
488
+ );
489
+ for (const { field, reports: fieldReports } of grouped) {
490
+ const reportList = fieldReports
491
+ .map(({ id }) => `H1 #${id}`)
492
+ .join(', ');
493
+ this.cli.info(
494
+ `- ${field} (${fieldReports.length}): ${reportList}`
495
+ );
496
+ }
497
+ }
498
+
265
499
  async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
266
500
  this.cli.startSpinner('Creating vulnerabilities.json...');
267
501
  const fileContent = JSON.stringify({
@@ -271,10 +505,19 @@ export default class PrepareSecurityRelease extends SecurityRelease {
271
505
  }, null, 2) + '\n';
272
506
 
273
507
  const folderPath = path.resolve(NEXT_SECURITY_RELEASE_FOLDER);
274
- await fs.promises.mkdir(folderPath, { recursive: true });
275
-
276
508
  const fullPath = path.join(folderPath, 'vulnerabilities.json');
277
- await fs.promises.writeFile(fullPath, fileContent);
509
+ await confirmSecurityStep(
510
+ this.cli,
511
+ `create directory \`${folderPath}\``,
512
+ 'This creates the security release folder if it does not already exist.'
513
+ );
514
+ await fs.promises.mkdir(folderPath, { recursive: true });
515
+ await writeSecurityFile(
516
+ this.cli,
517
+ fullPath,
518
+ fileContent,
519
+ 'This creates vulnerabilities.json for the next security release.'
520
+ );
278
521
  this.cli.stopSpinner(`Created ${fullPath}`);
279
522
 
280
523
  return fullPath;
@@ -282,6 +525,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
282
525
 
283
526
  async createPullRequest(content) {
284
527
  const { owner, repo } = this.repository;
528
+ await confirmSecurityStep(
529
+ this.cli,
530
+ `create GitHub pull request \`${owner}/${repo}: ${this.title}\``,
531
+ `This opens a pull request from ${NEXT_SECURITY_RELEASE_BRANCH} to main.`
532
+ );
285
533
  const response = await this.req.createPullRequest(
286
534
  this.title,
287
535
  content ?? 'List of vulnerabilities to be included in the next security release',
@@ -308,10 +556,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
308
556
  process.exit(1);
309
557
  }
310
558
 
559
+ // Collect dependency updates using the same schema as the reports: each
560
+ // dependency maps every affected release line to the PR that lands it, e.g.
561
+ // { undici: { affectedVersions: { '22.x': '<prURL>', '24.x': '<prURL>' } } }
311
562
  async getDependencyUpdates() {
312
- const deps = [];
313
- this.cli.log('\n');
314
- this.cli.separator('Dependency Updates');
563
+ const deps = {};
315
564
  const updates = await this.cli.prompt('Are there dependency updates in this security release?',
316
565
  {
317
566
  defaultAnswer: true,
@@ -322,11 +571,9 @@ export default class PrepareSecurityRelease extends SecurityRelease {
322
571
 
323
572
  const supportedVersions = await getSupportedVersions();
324
573
 
325
- let asking = true;
326
- while (asking) {
574
+ while (true) {
327
575
  const dep = await promptDependencies(this.cli);
328
576
  if (!dep) {
329
- asking = false;
330
577
  break;
331
578
  }
332
579
 
@@ -337,20 +584,24 @@ export default class PrepareSecurityRelease extends SecurityRelease {
337
584
  });
338
585
 
339
586
  const versions = await this.cli.prompt(
340
- 'Which release line does this dependency update affect?', {
587
+ 'Which release line(s) does this dependency update PR affect? ' +
588
+ '(comma-separated)', {
341
589
  defaultAnswer: supportedVersions,
342
590
  questionType: 'input'
343
591
  });
344
592
 
345
593
  try {
346
594
  const res = await this.req.getPullRequest(dep);
347
- const { html_url, title } = res;
348
- deps.push({
349
- name,
350
- url: html_url,
351
- title,
352
- affectedVersions: versions.split(',').map((v) => v.replace('v', '').trim())
353
- });
595
+ const { html_url: prURL } = res;
596
+ const affectedVersions = versions
597
+ .split(',')
598
+ .map((v) => v.replace('v', '').trim())
599
+ .filter(Boolean);
600
+
601
+ deps[name] ??= { affectedVersions: {} };
602
+ for (const line of affectedVersions) {
603
+ deps[name].affectedVersions[line] = prURL;
604
+ }
354
605
  this.cli.separator();
355
606
  } catch (error) {
356
607
  this.cli.error('Invalid PR url. Please provide a valid PR url.');
@@ -364,6 +615,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
364
615
  this.cli.startSpinner('Closing HackerOne reports');
365
616
  for (const report of jsonReports) {
366
617
  this.cli.updateSpinner(`Closing report ${report.id}...`);
618
+ await confirmSecurityStep(
619
+ this.cli,
620
+ `resolve HackerOne report \`${report.id}\``,
621
+ 'This marks the HackerOne report as resolved.'
622
+ );
367
623
  await this.req.updateReportState(
368
624
  report.id,
369
625
  'resolved',
@@ -371,6 +627,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
371
627
  );
372
628
 
373
629
  this.cli.updateSpinner(`Requesting disclosure to report ${report.id}...`);
630
+ await confirmSecurityStep(
631
+ this.cli,
632
+ `request disclosure for HackerOne report \`${report.id}\``,
633
+ 'This asks HackerOne to disclose the resolved report.'
634
+ );
374
635
  await this.req.requestDisclosure(report.id);
375
636
  }
376
637
  this.cli.stopSpinner('Done closing H1 Reports and requesting disclosure');
@@ -388,6 +649,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
388
649
  for (const pr of prs) {
389
650
  if (pr.labels.some((l) => labels.includes(l.name))) {
390
651
  this.cli.updateSpinner(`Closing Pull Request: ${pr.number}`);
652
+ await confirmSecurityStep(
653
+ this.cli,
654
+ `close GitHub pull request \`nodejs-private/node-private#${pr.number}\``,
655
+ 'This closes a pull request labeled for the security release.'
656
+ );
391
657
  await this.req.closePullRequest(pr.number,
392
658
  { owner: 'nodejs-private', repo: 'node-private' });
393
659
  }
@@ -5,7 +5,7 @@ import { pipeline } from 'node:stream/promises';
5
5
  import semver from 'semver';
6
6
  import * as gst from 'git-secure-tag';
7
7
 
8
- import { forceRunAsync } from './run.js';
8
+ import { forceRunAsync, runSync } from './run.js';
9
9
  import PRData from './pr_data.js';
10
10
  import PRChecker from './pr_checker.js';
11
11
  import Session from './session.js';
@@ -59,7 +59,8 @@ export default class ReleasePromotion extends Session {
59
59
  if (!localCloneIsClean) {
60
60
  if (await cli.prompt('Should we checkout the release proposal?')) {
61
61
  cli.startSpinner('Fetching the proposal upstream...');
62
- await forceRunAsync('git', ['fetch', proposalUpstreamRemote, releaseCommitSha],
62
+ // We need a sync spawn here to avoid the spinner getting in the way of SSH key passphrase
63
+ runSync('git', ['fetch', proposalUpstreamRemote, releaseCommitSha],
63
64
  { ignoreFailure: false });
64
65
  await forceRunAsync('git', ['checkout', releaseCommitSha], { ignoreFailure: false });
65
66
  cli.stopSpinner('Local HEAD is now in sync with the proposal');
@@ -523,11 +524,7 @@ export default class ReleasePromotion extends Session {
523
524
  cli.separator();
524
525
  cli.info('Resolve the conflicts and commit the result');
525
526
  cli.separator();
526
- const didResolveConflicts = await cli.prompt(
527
- 'Finished resolving cherry-pick conflicts?', { defaultAnswer: true });
528
- if (!didResolveConflicts) {
529
- throw new Error('Aborted');
530
- }
527
+ await cli.prompt('Ready to continue?', { defaultAnswer: true });
531
528
  }
532
529
 
533
530
  if (existsSync('.git/CHERRY_PICK_HEAD')) {
@@ -537,16 +534,22 @@ export default class ReleasePromotion extends Session {
537
534
  }
538
535
 
539
536
  // Validate release commit on the default branch
540
- const releaseCommitOnDefaultBranch =
541
- await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
542
- { captureStdout: true, ignoreFailure: false });
543
- const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
544
- await this.validateReleaseCommit(commitTitle);
545
- if (modifiedFiles.some(file => !file.endsWith('.md'))) {
546
- cli.warn('Some modified files are not markdown, that\'s unusual.');
537
+ while (true) {
538
+ const releaseCommitOnDefaultBranch =
539
+ await forceRunAsync('git', ['show', 'HEAD', '--name-only', '--pretty=format:%s'],
540
+ { captureStdout: true, ignoreFailure: false });
541
+ const [commitTitle, ...modifiedFiles] = releaseCommitOnDefaultBranch.trim().split('\n');
542
+ await this.validateReleaseCommit(commitTitle);
543
+ if (!modifiedFiles.some(file => !file.endsWith('.md'))) break;
544
+
545
+ cli.warn(
546
+ 'Some modified files are not markdown, that\'s unusual. Consider amending the commit.'
547
+ );
547
548
  cli.info(`The list of modified files: ${modifiedFiles.map(f => `- ${f}`).join('\n')}`);
548
- if (!await cli.prompt('Do you want to proceed anyway?', { defaultAnswer: false })) {
549
- throw new Error('Aborted');
549
+ if (await cli.prompt('Ignore and continue anyway?', {
550
+ defaultAnswer: false
551
+ })) {
552
+ break;
550
553
  }
551
554
  }
552
555
  }
@@ -0,0 +1,19 @@
1
+ query PR($owner: String!, $repo: String!) {
2
+ repository(owner: $owner, name: $repo) {
3
+ pullRequests(states: OPEN, first: 2, headRefName: "next-security-release", orderBy: {field: CREATED_AT, direction: DESC}) {
4
+ nodes {
5
+ number
6
+ headRefOid
7
+ author {
8
+ login
9
+ }
10
+
11
+ files(first: 2) {
12
+ nodes {
13
+ path
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
@@ -31,7 +31,10 @@ query PR($prid: Int!, $owner: String!, $repo: String!) {
31
31
  closed,
32
32
  closedAt,
33
33
  merged,
34
- mergedAt
34
+ mergedAt,
35
+ mergeCommit {
36
+ oid
37
+ }
35
38
  }
36
39
  }
37
40
  }
package/lib/request.js CHANGED
@@ -100,6 +100,47 @@ export default class Request {
100
100
  return this.json(prUrl);
101
101
  }
102
102
 
103
+ async * getPullRequestFiles({ owner, repo, prid }) {
104
+ let page = 1;
105
+ for (;;) {
106
+ const url =
107
+ `/repos/${owner}/${repo}/pulls/${prid}/files?per_page=100&page=${page}`;
108
+ const batch = await this.json(url);
109
+ if (!Array.isArray(batch) || batch.length === 0) {
110
+ break;
111
+ }
112
+ yield * batch;
113
+ if (batch.length < 100) {
114
+ break;
115
+ }
116
+ page++;
117
+ }
118
+ }
119
+
120
+ async listDirectory({ owner, repo, path, ref }) {
121
+ let url = `/repos/${owner}/${repo}/contents/${path}`;
122
+ if (ref) {
123
+ url += `?ref=${encodeURIComponent(ref)}`;
124
+ }
125
+ return this.json(url);
126
+ }
127
+
128
+ async dispatchWorkflow(workflowId, { owner, repo, ref, inputs }) {
129
+ const url =
130
+ `/repos/${owner}/${repo}/actions/workflows/${workflowId}/dispatches`;
131
+ const res = await this.fetch(url, {
132
+ method: 'POST',
133
+ body: JSON.stringify({ ref, inputs })
134
+ });
135
+ if (!res.ok) {
136
+ const text = await res.text();
137
+ throw new Error(
138
+ `Failed to dispatch workflow ${workflowId} on ${owner}/${repo}: ` +
139
+ `${res.status} ${res.statusText}${text ? `\n${text}` : ''}`);
140
+ }
141
+ return res;
142
+ }
143
+
103
144
  async createPullRequest(title, body, { owner, repo, head, base }) {
104
145
  const url = `/repos/${owner}/${repo}/pulls`;
105
146
  const options = {