@node-core/utils 7.2.2 → 7.3.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.
@@ -0,0 +1,150 @@
1
+ import { JobParser, CI_TYPES_KEYS } from './ci_type_parser.js';
2
+ import { CI_CRUMB_URL } from './run_ci.js';
3
+ import { PRBuild } from './build-types/pr_build.js';
4
+ import { getPrURL } from '../links.js';
5
+ import { debuglog } from '../verbosity.js';
6
+ import { FailureFileScanner } from './failure_file_scanner.js';
7
+
8
+ export class ResumePRJob {
9
+ constructor(cli, request, owner, repo, prid) {
10
+ this.cli = cli;
11
+ this.request = request;
12
+ this.owner = owner;
13
+ this.repo = repo;
14
+ this.prid = prid;
15
+ }
16
+
17
+ async checkFailures(jobid) {
18
+ const { cli, request, owner, repo, prid } = this;
19
+ const filenames = new Set();
20
+ for await (const file of request.getPullRequestFiles({ owner, repo, prid })) {
21
+ for (const filename of [file.filename, file.previous_filename].filter(Boolean)) {
22
+ filenames.add(filename);
23
+ }
24
+ }
25
+ const overlaps = new Set();
26
+ const buildRequest = Object.create(request);
27
+ buildRequest.json = async(...args) => {
28
+ const data = await request.json(...args);
29
+ // Inspect failed descendants even when their parent job was aborted.
30
+ return data?.result === 'ABORTED' ? { ...data, result: 'FAILURE' } : data;
31
+ };
32
+ // Reuse the build traversal, but consume console responses directly instead
33
+ // of handing whole logs to its summary parser. Serialize downloads so a
34
+ // match cancels this response and prevents queued logs from being opened.
35
+ let pending = Promise.resolve();
36
+ buildRequest.text = (url) => {
37
+ const scan = pending.then(async() => {
38
+ if (!filenames.size || overlaps.size) return '';
39
+ const scanner = new FailureFileScanner(filenames);
40
+ const match = await scanner.scan(request.stream(url));
41
+ if (match) overlaps.add(match);
42
+ return '';
43
+ });
44
+ pending = scan.catch(debuglog);
45
+ return scan;
46
+ };
47
+ const build = new PRBuild(cli, buildRequest, jobid, Infinity);
48
+ try {
49
+ await build.getResults();
50
+ } catch (err) {
51
+ debuglog(err);
52
+ }
53
+ await pending;
54
+ if (overlaps.size) {
55
+ for (const filename of [...overlaps].sort()) {
56
+ cli.error(filename);
57
+ }
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ async resume() {
64
+ const { cli, request, prid } = this;
65
+ let crumb;
66
+ cli.startSpinner('Validating Jenkins credentials');
67
+ try {
68
+ ({ crumb } = await request.json(CI_CRUMB_URL));
69
+ if (!crumb) {
70
+ throw new Error('Missing Jenkins crumb');
71
+ }
72
+ } catch (err) {
73
+ debuglog(err);
74
+ cli.stopSpinner('Jenkins credentials invalid', cli.SPINNER_STATUS.FAILED);
75
+ return false;
76
+ }
77
+ cli.stopSpinner('Jenkins credentials valid');
78
+
79
+ try {
80
+ cli.startSpinner(`Looking for CI runs for pull request ${prid}`);
81
+ const parser = await JobParser.fromPR(getPrURL(this), cli, request);
82
+ const job = parser.parse().get(CI_TYPES_KEYS.PR);
83
+ if (!job) {
84
+ cli.stopSpinner(`No CI run detected from pull request ${prid}`,
85
+ cli.SPINNER_STATUS.FAILED);
86
+ return false;
87
+ }
88
+ cli.stopSpinner(`Found PR CI job ${job.jobid}`);
89
+
90
+ const build = new PRBuild(cli, request, job.jobid, undefined,
91
+ 'result,building,actions[_class,parameters[name,value]]');
92
+ const { result, building, actions = [] } = await build.getBuildData();
93
+ if (building || (result !== 'FAILURE' && result !== 'ABORTED')) {
94
+ const status = building ? 'RUNNING' : result ?? 'RUNNING';
95
+ cli.error(`CI job ${job.jobid} is in status ${status}, skipping resume`);
96
+ return false;
97
+ }
98
+ if (!actions.some(action =>
99
+ action._class === 'com.tikal.jenkins.plugins.multijob.MultiJobResumeBuild')) {
100
+ cli.error(`CI job ${job.jobid} is not resumable`);
101
+ return false;
102
+ }
103
+
104
+ const approvedSHAs = new Set(actions.flatMap(action => action.parameters ?? [])
105
+ .filter(parameter => parameter.name === 'COMMIT_SHA_CHECK')
106
+ .map(parameter => parameter.value));
107
+ const [approvedSHA] = approvedSHAs;
108
+ if (approvedSHAs.size !== 1 || typeof approvedSHA !== 'string' || !approvedSHA) {
109
+ cli.error(`Refusing to resume CI job ${job.jobid}: cannot determine its approved commit`);
110
+ return false;
111
+ }
112
+
113
+ cli.startSpinner('Checking failures against changed PR files');
114
+ if (!(await this.checkFailures(job.jobid))) {
115
+ cli.stopSpinner('Refusing to resume CI: failures reference files changed by this PR',
116
+ cli.SPINNER_STATUS.FAILED);
117
+ return false;
118
+ }
119
+ cli.stopSpinner('No changed PR files found in available failure details');
120
+
121
+ // Read HEAD after inspecting failures so the comparison is fresh when resuming.
122
+ const pr = await request.getPullRequest(getPrURL(this));
123
+ if (pr.head?.sha !== approvedSHA) {
124
+ cli.error(`Refusing to resume CI job ${job.jobid}: ` +
125
+ 'its approved commit does not match the current PR HEAD');
126
+ return false;
127
+ }
128
+
129
+ cli.startSpinner(`Resuming PR CI job ${job.jobid}`);
130
+ const response = await request.fetch(`${build.jobUrl}resume`, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Jenkins-Crumb': crumb
134
+ }
135
+ });
136
+ if (response.status !== 200) {
137
+ cli.stopSpinner(
138
+ `Failed to resume PR CI: ${response.status} ${response.statusText}`,
139
+ cli.SPINNER_STATUS.FAILED);
140
+ return false;
141
+ }
142
+ cli.stopSpinner('PR CI job successfully resumed');
143
+ } catch (err) {
144
+ debuglog(err);
145
+ cli.stopSpinner('Failed to resume CI', cli.SPINNER_STATUS.FAILED);
146
+ return false;
147
+ }
148
+ return true;
149
+ }
150
+ }
package/lib/ci/run_ci.js CHANGED
@@ -94,10 +94,20 @@ export class RunPRJob {
94
94
  if (checkForDuplicates) {
95
95
  await this.prData.getComments();
96
96
  const { jobid, link } = new JobParser(this.prData.comments).parse().get('PR') ?? {};
97
- const { actions } = jobid
98
- ? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]')
99
- .getBuildData())
100
- : {};
97
+ let actions;
98
+ try {
99
+ ({ actions } = jobid
100
+ ? (await new PRBuild(cli, request, jobid, undefined, 'actions[parameters[name,value]]')
101
+ .getBuildData())
102
+ : {});
103
+ } catch (err) {
104
+ // Jenkins may no longer have the build (e.g. the record was lost) or
105
+ // may answer with an HTML error page instead of JSON. Do not let that
106
+ // block starting a new CI run.
107
+ cli.stopSpinner(`Could not query existing CI run ${link}: ${err.message}`,
108
+ cli.SPINNER_STATUS.WARN);
109
+ cli.warn('Skipping the duplicate CI check');
110
+ }
101
111
  const { parameters } = actions?.find(a => 'parameters' in a) ?? {};
102
112
  if (parameters?.find(c => c.name === 'COMMIT_SHA_CHECK')?.value === certifySafe) {
103
113
  cli.info('Existing CI run found: ' + link);
package/lib/cli.js CHANGED
@@ -154,7 +154,13 @@ export default class CLI {
154
154
  case INFO:
155
155
  symbol = info;
156
156
  }
157
- this.spinner.stop(`${symbol} ${rawText}`);
157
+ const text = `${symbol} ${rawText}`;
158
+ if (this.spinner.isSpinning) {
159
+ this.spinner.stop(text);
160
+ } else {
161
+ // A nested operation may already have stopped the shared spinner.
162
+ this.log(text);
163
+ }
158
164
  }
159
165
 
160
166
  write(text) {
@@ -1,24 +1,40 @@
1
- import fs from 'node:fs';
2
1
  import path from 'node:path';
3
2
  import auth from './auth.js';
4
3
  import Request from './request.js';
5
4
  import { parsePRFromURL } from './links.js';
5
+ import {
6
+ assertNewSecurityRelease,
7
+ getSecurityReleaseDraftPath,
8
+ writeSecurityReleaseDraft
9
+ } from './security-release/draft.js';
10
+ import {
11
+ buildIncludedTriagedReport,
12
+ getReportPRURL,
13
+ getMissingReportInformation,
14
+ groupMissingReportInformation,
15
+ listSecurityReleaseCandidates,
16
+ prepareSecurityRelease
17
+ } from './security-release/preparation.js';
18
+
6
19
  import {
7
20
  NEXT_SECURITY_RELEASE_BRANCH,
8
- NEXT_SECURITY_RELEASE_FOLDER,
9
21
  checkoutOnSecurityReleaseBranch,
10
22
  commitAndPushVulnerabilitiesJSON,
11
23
  validateDate,
12
24
  promptDependencies,
13
25
  getSupportedVersions,
14
26
  getReportSeverity,
15
- getSummary,
16
27
  pickReport,
17
28
  confirmSecurityStep,
18
- writeSecurityFile,
19
29
  SecurityRelease
20
30
  } from './security-release/security-release.js';
21
31
 
32
+ export {
33
+ buildIncludedTriagedReport,
34
+ getMissingReportInformation,
35
+ groupMissingReportInformation
36
+ } from './security-release/preparation.js';
37
+
22
38
  function relativeDate(date) {
23
39
  const days = Math.floor((Date.now() - date) / (1000 * 60 * 60 * 24));
24
40
  if (days < 30) return days === 1 ? '1 day ago' : `${days} days ago`;
@@ -82,79 +98,11 @@ export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count =
82
98
  return choices;
83
99
  }
84
100
 
85
- function getReportPRURL(report) {
86
- const customFieldValues = report.relationships.custom_field_values?.data ?? [];
87
- return customFieldValues[0]?.attributes?.value ?? '';
88
- }
89
-
90
- export function buildIncludedTriagedReport(report, options = {}) {
91
- const {
92
- affectedVersions = '',
93
- patchAuthors = [],
94
- prURL = getReportPRURL(report)
95
- } = options;
96
- const {
97
- id,
98
- attributes: { title, cve_ids = [] },
99
- relationships: { reporter }
100
- } = report;
101
- const link = `https://hackerone.com/reports/${id}`;
102
- const summaryContent = getSummary(report);
103
-
104
- return {
105
- id,
106
- title,
107
- cveIds: cve_ids,
108
- severity: getReportSeverity(report),
109
- summary: summaryContent ?? '',
110
- patchAuthors,
111
- prURL,
112
- affectedVersions: affectedVersions
113
- .split(',')
114
- .map((v) => v.replace('v', '').trim())
115
- .filter(Boolean),
116
- link,
117
- reporter: reporter?.data?.attributes?.username ?? ''
118
- };
119
- }
120
-
121
- export function getMissingReportInformation(report) {
122
- const missing = [];
123
-
124
- if (!report.severity?.rating) missing.push('severity rating');
125
- if (!report.severity?.cvss_vector_string) missing.push('CVSS vector');
126
- if (!report.severity?.weakness_id) missing.push('weakness ID');
127
- if (!report.summary) missing.push('team summary');
128
- if (!report.prURL) missing.push('PR URL');
129
- if (!report.patchAuthors?.length) missing.push('patch authors');
130
- if (!report.affectedVersions?.length) missing.push('affected versions');
131
-
132
- return missing;
133
- }
134
-
135
- export function groupMissingReportInformation(reports) {
136
- const grouped = new Map();
137
-
138
- for (const report of reports) {
139
- for (const field of report.missing) {
140
- const current = grouped.get(field) ?? [];
141
- current.push(report);
142
- grouped.set(field, current);
143
- }
144
- }
145
-
146
- return Array.from(grouped.entries())
147
- .map(([field, fieldReports]) => ({
148
- field,
149
- reports: fieldReports
150
- }))
151
- .sort((a, b) => b.reports.length - a.reports.length);
152
- }
153
-
154
101
  export default class PrepareSecurityRelease extends SecurityRelease {
155
102
  title = 'Next Security Release';
156
103
 
157
104
  async start() {
105
+ assertNewSecurityRelease(process.cwd());
158
106
  const credentials = await auth({
159
107
  github: true,
160
108
  h1: true
@@ -172,14 +120,15 @@ export default class PrepareSecurityRelease extends SecurityRelease {
172
120
  const content = await this.buildDescription(releaseDate);
173
121
  if (createVulnerabilitiesJSON) {
174
122
  const reportSelectionMode = await this.promptReportSelectionMode();
123
+ const candidates = await listSecurityReleaseCandidates(this.req);
175
124
  if (reportSelectionMode === 'review') {
176
125
  const showTriaged = await this.promptShowTriagedWithoutPR();
177
126
  if (showTriaged) {
178
- excludedReports = await this.showTriagedReportsWithoutPR();
127
+ excludedReports = await this.showTriagedReportsWithoutPR(candidates);
179
128
  }
180
129
  }
181
130
  await this.startVulnerabilitiesJSONCreation(
182
- releaseDate, content, excludedReports, reportSelectionMode);
131
+ releaseDate, content, excludedReports, reportSelectionMode, candidates);
183
132
  }
184
133
 
185
134
  this.cli.ok('Done!');
@@ -251,19 +200,25 @@ export default class PrepareSecurityRelease extends SecurityRelease {
251
200
  releaseDate,
252
201
  content,
253
202
  excludedReports = [],
254
- reportSelectionMode = 'review'
203
+ reportSelectionMode = 'review',
204
+ candidates
255
205
  ) {
256
- // checkout on the next-security-release branch
257
- await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
206
+ assertNewSecurityRelease(process.cwd());
258
207
 
259
208
  // choose the reports to include in the security release
260
209
  const reports = reportSelectionMode === 'include-all'
261
- ? await this.includeAllTriagedReports(excludedReports)
262
- : await this.chooseReports(excludedReports);
210
+ ? await this.includeAllTriagedReports(excludedReports, candidates)
211
+ : await this.chooseReports(excludedReports, candidates);
263
212
  const deps = await this.getDependencyUpdates();
213
+ const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies: deps });
264
214
 
265
- // create the vulnerabilities.json file in the security-release repo
266
- const filePath = await this.createVulnerabilitiesJSON(reports, deps, releaseDate);
215
+ // Prepare all data before changing Git state. An existing branch may contain
216
+ // a draft that was not present on the branch where this command started.
217
+ await checkoutOnSecurityReleaseBranch(this.cli, this.repository);
218
+ assertNewSecurityRelease(process.cwd());
219
+
220
+ const filePath = await this.createVulnerabilitiesJSON(
221
+ release.reports, release.dependencies, release.releaseDate);
267
222
 
268
223
  // review the vulnerabilities.json file
269
224
  const review = await this.promptReviewVulnerabilitiesJSON();
@@ -366,11 +321,11 @@ export default class PrepareSecurityRelease extends SecurityRelease {
366
321
  { defaultAnswer: true });
367
322
  }
368
323
 
369
- async showTriagedReportsWithoutPR() {
324
+ async showTriagedReportsWithoutPR(candidates) {
370
325
  this.cli.info('Fetching triaged reports without PR URL...');
371
- const reports = await this.req.getTriagedReports();
372
- const reportsWithoutPR = reports.data.filter(
373
- (report) => !report.relationships.custom_field_values.data.length
326
+ const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
327
+ const reportsWithoutPR = reports.filter(
328
+ (report) => !getReportPRURL(report)
374
329
  );
375
330
  if (!reportsWithoutPR.length) {
376
331
  this.cli.ok('All triaged reports have a PR URL.');
@@ -408,12 +363,12 @@ export default class PrepareSecurityRelease extends SecurityRelease {
408
363
  return template;
409
364
  }
410
365
 
411
- async chooseReports(excludedReports = []) {
366
+ async chooseReports(excludedReports = [], candidates) {
412
367
  this.cli.info('Getting triaged H1 reports...');
413
- const reports = await this.req.getTriagedReports();
368
+ const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
414
369
  const selectedReports = [];
415
370
 
416
- for (const report of reports.data) {
371
+ for (const report of reports) {
417
372
  if (excludedReports.includes(report.id)) continue;
418
373
  const rep = await pickReport(report, { cli: this.cli, req: this.req });
419
374
  if (!rep) continue;
@@ -422,14 +377,14 @@ export default class PrepareSecurityRelease extends SecurityRelease {
422
377
  return selectedReports;
423
378
  }
424
379
 
425
- async includeAllTriagedReports(excludedReports = []) {
380
+ async includeAllTriagedReports(excludedReports = [], candidates) {
426
381
  this.cli.info('Getting triaged H1 reports...');
427
- const reports = await this.req.getTriagedReports();
382
+ const reports = candidates ?? await listSecurityReleaseCandidates(this.req);
428
383
  const supportedVersions = await getSupportedVersions();
429
384
  const selectedReports = [];
430
385
  const missingInformation = [];
431
386
 
432
- for (const report of reports.data) {
387
+ for (const report of reports) {
433
388
  if (excludedReports.includes(report.id)) continue;
434
389
 
435
390
  const reportData = await this.buildIncludedTriagedReport(
@@ -497,29 +452,23 @@ export default class PrepareSecurityRelease extends SecurityRelease {
497
452
  }
498
453
 
499
454
  async createVulnerabilitiesJSON(reports, dependencies, releaseDate) {
500
- this.cli.startSpinner('Creating vulnerabilities.json...');
501
- const fileContent = JSON.stringify({
502
- releaseDate,
503
- reports,
504
- dependencies
505
- }, null, 2) + '\n';
506
-
507
- const folderPath = path.resolve(NEXT_SECURITY_RELEASE_FOLDER);
508
- const fullPath = path.join(folderPath, 'vulnerabilities.json');
455
+ const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies });
456
+ const directory = process.cwd();
457
+ const fullPath = getSecurityReleaseDraftPath(directory);
458
+ assertNewSecurityRelease(directory);
509
459
  await confirmSecurityStep(
510
460
  this.cli,
511
- `create directory \`${folderPath}\``,
461
+ `create directory \`${path.dirname(fullPath)}\``,
512
462
  'This creates the security release folder if it does not already exist.'
513
463
  );
514
- await fs.promises.mkdir(folderPath, { recursive: true });
515
- await writeSecurityFile(
464
+ await confirmSecurityStep(
516
465
  this.cli,
517
- fullPath,
518
- fileContent,
466
+ `write \`${fullPath}\``,
519
467
  'This creates vulnerabilities.json for the next security release.'
520
468
  );
469
+ this.cli.startSpinner('Creating vulnerabilities.json...');
470
+ writeSecurityReleaseDraft(directory, release);
521
471
  this.cli.stopSpinner(`Created ${fullPath}`);
522
-
523
472
  return fullPath;
524
473
  }
525
474
 
package/lib/request.js CHANGED
@@ -58,6 +58,17 @@ export default class Request {
58
58
  return res.text();
59
59
  }
60
60
 
61
+ async * stream(url, options = {}) {
62
+ const res = await this.fetch(url, options);
63
+ if (!res.ok) {
64
+ await res.body?.cancel();
65
+ throw new Error(`Unable to stream ${url}: ${res.status} ${res.statusText}`);
66
+ }
67
+ // Undici decompresses HTTP content encodings as it reads. Returning early
68
+ // from this iterator also cancels the response body and its HTTP download.
69
+ yield * res.body;
70
+ }
71
+
61
72
  async json(url, options = {}) {
62
73
  options.headers = options.headers || {};
63
74
  const text = await this.text(url, options);
@@ -106,7 +117,10 @@ export default class Request {
106
117
  const url =
107
118
  `/repos/${owner}/${repo}/pulls/${prid}/files?per_page=100&page=${page}`;
108
119
  const batch = await this.json(url);
109
- if (!Array.isArray(batch) || batch.length === 0) {
120
+ if (!Array.isArray(batch)) {
121
+ throw new Error('Unable to retrieve pull request files');
122
+ }
123
+ if (batch.length === 0) {
110
124
  break;
111
125
  }
112
126
  yield * batch;
@@ -193,22 +207,52 @@ export default class Request {
193
207
  }
194
208
 
195
209
  async getTriagedReports() {
196
- const url = 'https://api.hackerone.com/v1/reports?filter[program][]=nodejs&filter[state][]=triaged';
210
+ let url = 'https://api.hackerone.com/v1/reports?filter[program][]=nodejs&filter[state][]=triaged';
197
211
  const options = {
198
212
  method: 'GET',
213
+ redirect: 'error',
199
214
  headers: {
200
215
  Authorization: `Basic ${this.credentials.h1}`,
201
216
  'User-Agent': 'node-core-utils',
202
217
  Accept: 'application/json'
203
218
  }
204
219
  };
205
- const data = await this.json(url, options);
206
- if (data?.errors) {
207
- throw new Error(
208
- `Request to fetch triaged reports failed with: ${JSON.stringify(data.errors)}`
209
- );
220
+ const reports = [];
221
+ const reportIds = new Set();
222
+ const pages = new Set();
223
+ let result;
224
+
225
+ while (url) {
226
+ const pageUrl = new URL(url);
227
+ if (pageUrl.origin !== 'https://api.hackerone.com' ||
228
+ pageUrl.pathname !== '/v1/reports' || pageUrl.username || pageUrl.password) {
229
+ throw new Error('Invalid HackerOne reports pagination URL');
230
+ }
231
+ if (pages.has(pageUrl.href)) {
232
+ throw new Error('Repeated HackerOne reports pagination URL');
233
+ }
234
+ pages.add(pageUrl.href);
235
+
236
+ result = await this.json(url, options);
237
+ if (result?.errors?.length) {
238
+ throw new Error(
239
+ `Request to fetch triaged reports failed with: ${JSON.stringify(result.errors)}`
240
+ );
241
+ }
242
+ if (!Array.isArray(result?.data)) {
243
+ throw new Error('Invalid HackerOne reports response: expected a data array');
244
+ }
245
+ for (const report of result.data) {
246
+ if (reportIds.has(report.id)) continue;
247
+ reportIds.add(report.id);
248
+ reports.push(report);
249
+ }
250
+
251
+ const next = result.links?.next;
252
+ url = next ? new URL(next, pageUrl).href : null;
210
253
  }
211
- return data;
254
+
255
+ return { ...result, data: reports };
212
256
  }
213
257
 
214
258
  async getPrograms() {
@@ -0,0 +1,32 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { NEXT_SECURITY_RELEASE_FOLDER } from './security-release.js';
5
+ import { prepareSecurityRelease } from './preparation.js';
6
+
7
+ export function getSecurityReleaseDraftPath(directory) {
8
+ if (typeof directory !== 'string' || !directory.trim()) {
9
+ throw new Error('Security release repository directory is required');
10
+ }
11
+ return path.resolve(directory, NEXT_SECURITY_RELEASE_FOLDER, 'vulnerabilities.json');
12
+ }
13
+
14
+ export function assertNewSecurityRelease(directory) {
15
+ const file = getSecurityReleaseDraftPath(directory);
16
+ if (fs.existsSync(file)) {
17
+ throw new Error(
18
+ `Security release draft already exists: ${file}. ` +
19
+ 'Use --sync, --add-report, or --remove-report to update the existing release.'
20
+ );
21
+ }
22
+ }
23
+
24
+ // Local persistence only. The caller handles review, Git, and publication.
25
+ export function writeSecurityReleaseDraft(directory, draft) {
26
+ const { release } = prepareSecurityRelease(draft);
27
+ const file = getSecurityReleaseDraftPath(directory);
28
+ assertNewSecurityRelease(directory);
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ fs.writeFileSync(file, JSON.stringify(release, null, 2) + '\n', { flag: 'wx' });
31
+ return file;
32
+ }