From 006422bb557b782d0e318bb62f76b5fdf2afdb37 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 3 Nov 2026 18:44:32 +0100 Subject: [PATCH] feat(git-node): simplify `security --start` Do not require the local CWD to be the private repo. --- docs/git-node.md | 36 ++++-- lib/prepare_security.js | 178 +++++++++++++++++----------- lib/request.js | 57 +++++++++ test/unit/security_draft.test.js | 17 --- test/unit/security_git.test.js | 64 ----------- test/unit/security_release.test.js | 179 +++++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 162 deletions(-) diff --git a/docs/git-node.md b/docs/git-node.md index 3dcf5f0e..79cb14fb 100644 --- a/docs/git-node.md +++ b/docs/git-node.md @@ -598,22 +598,33 @@ $ ncu-config --global set h1_username $H1_TOKEN access. - `h1_username`: HackerOne API Token username. +`git node security --start` pushes `vulnerabilities.json` to +`nodejs-private/security-release` through the GitHub API using the token NCU +is configured with, not your local git credentials. That token needs: + +- Classic token: the `repo` scope. +- Fine-grained token: access to `nodejs-private/security-release` with + **Contents: Read and write** (to create the `next-security-release` branch + and commit the file) and **Pull requests: Read and write** (to open the + pull request), plus **Pull requests: Read** on `nodejs-private/node-private` + to look up patch authors. + ### `git node security --start` This command prepares `vulnerabilities.json` and can open the Next Security Release pull request in `nodejs-private/security-release`, following the [Security Release Process][] document. It retrieves all pages of triaged HackerOne reports and uses the same candidate list for exclusions and selection. -The CLI prompts for the release date, report selection, and dependency updates -before writing the draft. Commit, push, and PR creation remain separate -confirmed steps. An existing `next-security-release` branch is checked out -without resetting it. If only `origin/next-security-release` exists locally, -the command creates a tracking branch from that ref. Fetch first when remote -state may have changed. - -Security release commits reject unrelated staged changes before staging their -own files. Commit or unstage those changes before continuing. `--start` refuses -to overwrite an existing draft, including one found after switching branches. +The CLI prompts for the release date, report selection, and dependency updates, +then generates the draft in memory (optionally opening it in your editor for +review; the edited file must still be a valid draft). It pushes the draft to the +`next-security-release` branch of `nodejs-private/security-release` using the +GitHub API, so it doesn't need a local clone of that repository. Branch +creation, the push, and PR creation remain separate confirmed steps. + +`--start` aborts before prompting for reports if the `next-security-release` +branch already exists upstream, or if `main` already contains +`security-release/next-security-release/vulnerabilities.json`. Use `--sync`, `--add-report`, or `--remove-report` to update that release. #### Preparing release data without the CLI @@ -652,8 +663,9 @@ After reviewing the prepared data, local tools can use repository path. The helper validates the draft and creates `security-release/next-security-release/vulnerabilities.json` with an exclusive write, so an existing file cannot be overwritten. It does not change Git state -or publish anything. Review and authorization belong to the calling tool; the -CLI retains its directory and file-write confirmations. +or publish anything. Review and authorization belong to the calling tool. +`git node security --start` does not use it: it publishes through the GitHub +API instead of writing to a local checkout. ### `git node security --apply-patches` diff --git a/lib/prepare_security.js b/lib/prepare_security.js index b8ff5fb9..a6676e47 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -1,12 +1,11 @@ +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import auth from './auth.js'; import Request from './request.js'; +import { forceRunAsync } from './run.js'; +import { getEditor } from './utils.js'; import { parsePRFromURL } from './links.js'; -import { - assertNewSecurityRelease, - getSecurityReleaseDraftPath, - writeSecurityReleaseDraft -} from './security-release/draft.js'; import { buildIncludedTriagedReport, getReportPRURL, @@ -18,7 +17,7 @@ import { import { NEXT_SECURITY_RELEASE_BRANCH, - checkoutOnSecurityReleaseBranch, + NEXT_SECURITY_RELEASE_FOLDER, commitAndPushVulnerabilitiesJSON, validateDate, promptDependencies, @@ -102,7 +101,6 @@ export default class PrepareSecurityRelease extends SecurityRelease { title = 'Next Security Release'; async start() { - assertNewSecurityRelease(process.cwd()); const credentials = await auth({ github: true, h1: true @@ -115,25 +113,43 @@ export default class PrepareSecurityRelease extends SecurityRelease { if (releaseDate !== 'TBD') { validateDate(releaseDate); } - const createVulnerabilitiesJSON = await this.promptVulnerabilitiesJSON(); const content = await this.buildDescription(releaseDate); - if (createVulnerabilitiesJSON) { - const reportSelectionMode = await this.promptReportSelectionMode(); - const candidates = await listSecurityReleaseCandidates(this.req); - if (reportSelectionMode === 'review') { - const showTriaged = await this.promptShowTriagedWithoutPR(); - if (showTriaged) { - excludedReports = await this.showTriagedReportsWithoutPR(candidates); - } + await this.checkBranchDoesNotExist(); + const reportSelectionMode = await this.promptReportSelectionMode(); + const candidates = await listSecurityReleaseCandidates(this.req); + if (reportSelectionMode === 'review') { + const showTriaged = await this.promptShowTriagedWithoutPR(); + if (showTriaged) { + excludedReports = await this.showTriagedReportsWithoutPR(candidates); } - await this.startVulnerabilitiesJSONCreation( - releaseDate, content, excludedReports, reportSelectionMode, candidates); } + await this.startVulnerabilitiesJSONCreation( + releaseDate, content, excludedReports, reportSelectionMode, candidates); this.cli.ok('Done!'); } + async checkBranchDoesNotExist() { + const { owner, repo } = this.repository; + const branch = NEXT_SECURITY_RELEASE_BRANCH; + this.cli.startSpinner(`Checking that ${branch} does not exist on ${owner}/${repo}...`); + if (await this.req.getBranchSHA({ owner, repo, branch })) { + this.cli.stopSpinner(`${branch} already exists on ${owner}/${repo}`, + this.cli.SPINNER_STATUS.FAILED); + throw new Error('A security release seems to be in progress. Delete the ' + + `https://github.com/${owner}/${repo}/tree/${branch} branch if it is stale.`); + } + const draft = `${NEXT_SECURITY_RELEASE_FOLDER}/vulnerabilities.json`; + if (await this.req.getFileSHA({ owner, repo, path: draft, ref: 'main' })) { + this.cli.stopSpinner(`${draft} already exists on ${owner}/${repo}@main`, + this.cli.SPINNER_STATUS.FAILED); + throw new Error(`Security release draft already exists: ${draft} on main. ` + + 'Use --sync, --add-report, or --remove-report to update the existing release.'); + } + this.cli.stopSpinner(`${branch} does not exist on ${owner}/${repo}`); + } + async cleanup() { const credentials = await auth({ github: true, @@ -203,8 +219,6 @@ export default class PrepareSecurityRelease extends SecurityRelease { reportSelectionMode = 'review', candidates ) { - assertNewSecurityRelease(process.cwd()); - // choose the reports to include in the security release const reports = reportSelectionMode === 'include-all' ? await this.includeAllTriagedReports(excludedReports, candidates) @@ -212,46 +226,23 @@ export default class PrepareSecurityRelease extends SecurityRelease { const deps = await this.getDependencyUpdates(); const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies: deps }); - // Prepare all data before changing Git state. An existing branch may contain - // a draft that was not present on the branch where this command started. - await checkoutOnSecurityReleaseBranch(this.cli, this.repository); - assertNewSecurityRelease(process.cwd()); + let fileContent = this.createVulnerabilitiesJSON(release); - const filePath = await this.createVulnerabilitiesJSON( - release.reports, release.dependencies, release.releaseDate); - - // review the vulnerabilities.json file const review = await this.promptReviewVulnerabilitiesJSON(); - - if (!review) { - this.cli.info(`To push the vulnerabilities.json file run: - - git add ${filePath} - - git commit -m "chore: create vulnerabilities.json for next security release" - - git push -u origin ${NEXT_SECURITY_RELEASE_BRANCH} - - open a PR on ${this.repository.owner}/${this.repository.repo}`); - return; + if (review) { + fileContent = await this.editVulnerabilitiesJSON(fileContent); } - // commit and push the vulnerabilities.json file - const commitMessage = 'chore: create vulnerabilities.json for next security release'; - await commitAndPushVulnerabilitiesJSON(filePath, - commitMessage, - { cli: this.cli, repository: this.repository }); - - const createPr = await this.promptCreatePR(); - - if (!createPr) return; + await this.pushVulnerabilitiesJSON( + fileContent, + 'chore: create vulnerabilities.json for next security release', + { branchExists: false } + ); // create pr on the security-release repo return this.createPullRequest(content); } - promptCreatePR() { - return this.cli.prompt( - 'Create the Next Security Release PR?', - { defaultAnswer: true }); - } - async getSecurityIssueTemplate() { const url = 'https://raw.githubusercontent.com/nodejs/node/main/doc/contributing/security-release-process.md'; try { @@ -311,10 +302,37 @@ export default class PrepareSecurityRelease extends SecurityRelease { async promptReviewVulnerabilitiesJSON() { return this.cli.prompt( - 'Please review vulnerabilities.json and press enter to proceed.', + 'Do you want to review/edit vulnerabilities.json before pushing it?', { defaultAnswer: true }); } + async editVulnerabilitiesJSON(content) { + const editor = await getEditor({ git: true }); + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ncu-security-')); + const filePath = path.join(dir, 'vulnerabilities.json'); + try { + await fs.promises.writeFile(filePath, content); + for (;;) { + await forceRunAsync(editor, [`"${filePath}"`], + { ignoreFailure: false, spawnArgs: { shell: true } }); + const edited = await fs.promises.readFile(filePath, 'utf8'); + try { + const { release } = prepareSecurityRelease(JSON.parse(edited)); + return this.createVulnerabilitiesJSON(release); + } catch (error) { + this.cli.error(`vulnerabilities.json is not valid: ${error.message}`); + const retry = await this.cli.prompt('Edit vulnerabilities.json again?', + { defaultAnswer: true }); + if (!retry) { + throw new Error('Aborted: vulnerabilities.json is not valid.', { cause: error }); + } + } + } + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + } + async promptShowTriagedWithoutPR() { return this.cli.prompt( 'Do you want to see which reports are triaged but have no PR URL?', @@ -451,25 +469,47 @@ export default class PrepareSecurityRelease extends SecurityRelease { } } - async createVulnerabilitiesJSON(reports, dependencies, releaseDate) { - const { release } = prepareSecurityRelease({ releaseDate, reports, dependencies }); - const directory = process.cwd(); - const fullPath = getSecurityReleaseDraftPath(directory); - assertNewSecurityRelease(directory); - await confirmSecurityStep( - this.cli, - `create directory \`${path.dirname(fullPath)}\``, - 'This creates the security release folder if it does not already exist.' - ); + createVulnerabilitiesJSON(release) { + return JSON.stringify(release, null, 2) + '\n'; + } + + async pushVulnerabilitiesJSON(content, message, { branchExists } = {}) { + const { owner, repo } = this.repository; + const branch = NEXT_SECURITY_RELEASE_BRANCH; + const filePath = `${NEXT_SECURITY_RELEASE_FOLDER}/vulnerabilities.json`; + + this.cli.startSpinner(`Looking for ${branch} on ${owner}/${repo}...`); + branchExists ??= !!(await this.req.getBranchSHA({ owner, repo, branch })); + let fileSHA; + if (branchExists) { + fileSHA = await this.req.getFileSHA({ owner, repo, path: filePath, ref: branch }); + this.cli.stopSpinner(`Found ${branch} on ${owner}/${repo}`); + } else { + const baseSHA = await this.req.getBranchSHA({ owner, repo, branch: 'main' }); + if (!baseSHA) { + this.cli.stopSpinner(`Could not find main on ${owner}/${repo}`, + this.cli.SPINNER_STATUS.FAILED); + throw new Error(`Could not find main on ${owner}/${repo}`); + } + this.cli.stopSpinner(`Found main on ${owner}/${repo}`); + await confirmSecurityStep( + this.cli, + `create branch \`${branch}\` on \`${owner}/${repo}\``, + `This creates the ${branch} branch from main (${baseSHA}).` + ); + await this.req.createBranch({ owner, repo, branch, sha: baseSHA }); + } + await confirmSecurityStep( this.cli, - `write \`${fullPath}\``, - 'This creates vulnerabilities.json for the next security release.' + `push \`${filePath}\` to \`${owner}/${repo}@${branch}\``, + `This ${fileSHA ? 'replaces' : 'creates'} ${filePath} in a new commit: ${message}` ); - this.cli.startSpinner('Creating vulnerabilities.json...'); - writeSecurityReleaseDraft(directory, release); - this.cli.stopSpinner(`Created ${fullPath}`); - return fullPath; + this.cli.startSpinner(`Pushing ${filePath} to ${owner}/${repo}@${branch}...`); + const { commit } = await this.req.createOrUpdateFile({ + owner, repo, path: filePath, branch, content, message, sha: fileSHA + }); + this.cli.stopSpinner(`Pushed ${commit?.html_url ?? message}`); } async createPullRequest(content) { @@ -486,7 +526,7 @@ export default class PrepareSecurityRelease extends SecurityRelease { owner, repo, base: 'main', - head: 'next-security-release' + head: NEXT_SECURITY_RELEASE_BRANCH } ); diff --git a/lib/request.js b/lib/request.js index 82350476..03201d2a 100644 --- a/lib/request.js +++ b/lib/request.js @@ -155,6 +155,63 @@ export default class Request { return res; } + async getBranchSHA({ owner, repo, branch }) { + const url = `/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`; + const res = await this.fetch(url, {}); + if (res.status === 404) return; + if (!res.ok) { + throw new Error( + `Failed to get ${branch} on ${owner}/${repo}: ${res.status} ${res.statusText}`); + } + const { object } = await res.json(); + return object.sha; + } + + async createBranch({ owner, repo, branch, sha }) { + const url = `/repos/${owner}/${repo}/git/refs`; + const res = await this.fetch(url, { + method: 'POST', + body: JSON.stringify({ ref: `refs/heads/${branch}`, sha }) + }); + if (!res.ok) { + throw new Error( + `Failed to create ${branch} on ${owner}/${repo}: ${res.status} ${res.statusText}`); + } + return res.json(); + } + + async getFileSHA({ owner, repo, path, ref }) { + const url = `/repos/${owner}/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`; + const res = await this.fetch(url, {}); + if (res.status === 404) return; + if (!res.ok) { + throw new Error( + `Failed to get ${path} on ${owner}/${repo}@${ref}: ${res.status} ${res.statusText}`); + } + const { sha } = await res.json(); + return sha; + } + + async createOrUpdateFile({ owner, repo, path, branch, content, message, sha }) { + const url = `/repos/${owner}/${repo}/contents/${path}`; + const res = await this.fetch(url, { + method: 'PUT', + body: JSON.stringify({ + message, + content: Buffer.from(content).toString('base64'), + branch, + sha + }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error( + `Failed to push ${path} to ${owner}/${repo}@${branch}: ` + + `${res.status} ${res.statusText}${text ? `\n${text}` : ''}`); + } + return res.json(); + } + async createPullRequest(title, body, { owner, repo, head, base }) { const url = `/repos/${owner}/${repo}/pulls`; const options = { diff --git a/test/unit/security_draft.test.js b/test/unit/security_draft.test.js index 50ff0769..3dbb6a9d 100644 --- a/test/unit/security_draft.test.js +++ b/test/unit/security_draft.test.js @@ -9,7 +9,6 @@ import { getSecurityReleaseDraftPath, writeSecurityReleaseDraft } from '../../lib/security-release/draft.js'; -import PrepareSecurityRelease from '../../lib/prepare_security.js'; function directory(t) { const previous = process.cwd(); @@ -69,20 +68,4 @@ describe('security release draft persistence', () => { assert.deepStrictEqual(fs.readdirSync(dir), []); assert.throws(() => getSecurityReleaseDraftPath(), /directory is required/); }); - - it('does not write a draft when the file-write confirmation is declined', async(t) => { - const dir = directory(t); - const previous = process.cwd(); - t.after(() => process.chdir(previous)); - process.chdir(dir); - let prompts = 0; - const release = new PrepareSecurityRelease({ - async prompt() { - return ++prompts === 1; - } - }); - await assert.rejects(release.createVulnerabilitiesJSON([], {}, 'TBD'), /Aborted: write/); - assert.strictEqual(prompts, 2); - assert.deepStrictEqual(fs.readdirSync(dir), []); - }); }); diff --git a/test/unit/security_git.test.js b/test/unit/security_git.test.js index c08efb98..d0f60019 100644 --- a/test/unit/security_git.test.js +++ b/test/unit/security_git.test.js @@ -6,7 +6,6 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import PrepareSecurityRelease from '../../lib/prepare_security.js'; -import { writeSecurityReleaseDraft } from '../../lib/security-release/draft.js'; import { checkoutOnSecurityReleaseBranch, @@ -122,18 +121,6 @@ describe('security release git state', { concurrency: false }, () => { assert.strictEqual(git('write-tree'), index); }); - it('rejects an existing draft before prompting or fetching reports', async(t) => { - const dir = repository(t); - const file = writeSecurityReleaseDraft(dir, { releaseDate: 'TBD', reports: [] }); - const before = fs.readFileSync(file); - const release = new PrepareSecurityRelease({ - prompt() { assert.fail('Existing releases must not start again'); } - }); - await assert.rejects(release.start(), /draft already exists/); - assert.strictEqual(git('branch', '--show-current'), 'main'); - assert.deepStrictEqual(fs.readFileSync(file), before); - }); - it('preserves Git state if draft preparation fails', async(t) => { repository(t); const release = new PrepareSecurityRelease(cli); @@ -147,55 +134,4 @@ describe('security release git state', { concurrency: false }, () => { assert.strictEqual(git('branch', '--list', 'next-security-release'), ''); assert.strictEqual(git('status', '--porcelain'), ''); }); - - it('preserves a draft discovered on the existing release branch', async(t) => { - const dir = repository(t); - git('checkout', '-b', 'next-security-release'); - const file = writeSecurityReleaseDraft(dir, { releaseDate: 'TBD', reports: [] }); - const before = fs.readFileSync(file); - git('add', 'security-release'); - git('commit', '-m', 'Existing release'); - const head = git('rev-parse', 'HEAD'); - git('checkout', 'main'); - assert.ok(!fs.existsSync(file)); - - const release = new PrepareSecurityRelease(cli); - release.chooseReports = async() => []; - release.getDependencyUpdates = async() => ({}); - await assert.rejects( - release.startVulnerabilitiesJSONCreation('2026-10-06', 'Release'), /draft already exists/); - - assert.deepStrictEqual(fs.readFileSync(file), before); - assert.strictEqual(git('rev-parse', 'HEAD'), head); - assert.strictEqual(git('status', '--porcelain'), ''); - }); - - it('creates a local draft without committing when publication is declined', async(t) => { - const dir = repository(t); - fs.writeFileSync('user-notes.txt', 'Staged user work\n'); - git('add', 'user-notes.txt'); - const index = git('write-tree'); - const head = git('rev-parse', 'HEAD'); - const release = new PrepareSecurityRelease({ - ...cli, startSpinner() {}, stopSpinner() {} - }); - release.chooseReports = async() => []; - release.getDependencyUpdates = async() => ({ - undici: { affectedVersions: { '24.x': 'https://github.com/nodejs/node/pull/1' } } - }); - release.promptReviewVulnerabilitiesJSON = async() => false; - release.createPullRequest = async() => assert.fail('Do not publish a local draft'); - - await release.startVulnerabilitiesJSONCreation('2026/10/06', 'Release'); - - const file = path.join(dir, 'security-release/next-security-release/vulnerabilities.json'); - const draft = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.strictEqual(draft.releaseDate, '2026-10-06'); - assert.deepStrictEqual(draft.reports, []); - assert.strictEqual(draft.dependencies.undici.affectedVersions['24.x'], - 'https://github.com/nodejs/node/pull/1'); - assert.strictEqual(git('write-tree'), index); - assert.strictEqual(git('rev-parse', 'HEAD'), head); - assert.strictEqual(git('branch', '--show-current'), 'next-security-release'); - }); }); diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index a5447714..611a3607 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -1,5 +1,8 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import SecurityBlog from '../../lib/security_blog.js'; import PrepareSecurityRelease, { @@ -761,6 +764,182 @@ describe('security_release: include all triaged reports', () => { }); }); +describe('security_release: push vulnerabilities.json', () => { + function setup({ branchSHA, fileSHA }) { + const calls = []; + const release = new PrepareSecurityRelease({ + SPINNER_STATUS: { FAILED: 'failed', INFO: 'info' }, + startSpinner() {}, + stopSpinner() {}, + prompt(message) { + calls.push(['prompt', message]); + return true; + } + }); + release.req = { + getBranchSHA({ branch }) { + calls.push(['getBranchSHA', branch]); + return branch === 'main' ? 'main-sha' : branchSHA; + }, + getFileSHA(opts) { + calls.push(['getFileSHA', opts]); + return fileSHA; + }, + createBranch(opts) { + calls.push(['createBranch', opts]); + }, + createOrUpdateFile(opts) { + calls.push(['createOrUpdateFile', opts]); + return { commit: { html_url: 'https://example.com/commit' } }; + } + }; + return { release, calls }; + } + + const file = { + owner: 'nodejs-private', + repo: 'security-release', + path: 'security-release/next-security-release/vulnerabilities.json', + branch: 'next-security-release', + content: '{}\n', + message: 'msg' + }; + + it('creates the branch from main when missing', async() => { + const { release, calls } = setup({}); + await release.pushVulnerabilitiesJSON('{}\n', 'msg'); + assert.deepStrictEqual(calls.filter(([n]) => n !== 'prompt'), [ + ['getBranchSHA', 'next-security-release'], + ['getBranchSHA', 'main'], + ['createBranch', { + owner: 'nodejs-private', + repo: 'security-release', + branch: 'next-security-release', + sha: 'main-sha' + }], + ['createOrUpdateFile', { ...file, sha: undefined }] + ]); + assert.strictEqual(calls.filter(([n]) => n === 'prompt').length, 2); + }); + + it('skips the branch lookup when told the branch does not exist', async() => { + const { release, calls } = setup({ branchSHA: 'branch-sha' }); + await release.pushVulnerabilitiesJSON('{}\n', 'msg', { branchExists: false }); + assert.deepStrictEqual(calls.filter(([n]) => n !== 'prompt').map(([n]) => n), [ + 'getBranchSHA', + 'createBranch', + 'createOrUpdateFile' + ]); + assert.strictEqual(calls[0][1], 'main'); + }); + + it('refuses to start when the branch already exists', async() => { + const { release } = setup({ branchSHA: 'branch-sha' }); + await assert.rejects(() => release.checkBranchDoesNotExist(), + /A security release seems to be in progress/); + }); + + it('refuses to start when a draft already exists on main', async() => { + const { release } = setup({ fileSHA: 'file-sha' }); + await assert.rejects(() => release.checkBranchDoesNotExist(), /draft already exists/); + }); + + it('starts when the branch does not exist', async() => { + const { release, calls } = setup({}); + await release.checkBranchDoesNotExist(); + assert.deepStrictEqual(calls, [ + ['getBranchSHA', 'next-security-release'], + ['getFileSHA', { + owner: 'nodejs-private', + repo: 'security-release', + path: 'security-release/next-security-release/vulnerabilities.json', + ref: 'main' + }] + ]); + }); + + it('updates the file on an existing branch', async() => { + const { release, calls } = setup({ branchSHA: 'branch-sha', fileSHA: 'file-sha' }); + await release.pushVulnerabilitiesJSON('{}\n', 'msg'); + assert.deepStrictEqual(calls.filter(([n]) => n !== 'prompt'), [ + ['getBranchSHA', 'next-security-release'], + ['getFileSHA', { + owner: 'nodejs-private', + repo: 'security-release', + path: 'security-release/next-security-release/vulnerabilities.json', + ref: 'next-security-release' + }], + ['createOrUpdateFile', { ...file, sha: 'file-sha' }] + ]); + }); +}); + +describe('security_release: edit vulnerabilities.json', () => { + // Fake editor: replaces the file with the contents of the next answer. + function useEditor(t, answers) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ncu-test-editor-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const answersPath = path.join(dir, 'answers.json'); + fs.writeFileSync(answersPath, JSON.stringify(answers)); + const script = path.join(dir, 'editor.mjs'); + fs.writeFileSync(script, ` + import fs from 'node:fs'; + const answers = JSON.parse(fs.readFileSync(${JSON.stringify(answersPath)}, 'utf8')); + fs.writeFileSync(process.argv[2], answers.shift()); + fs.writeFileSync(${JSON.stringify(answersPath)}, JSON.stringify(answers)); + `); + const previous = process.env.GIT_EDITOR; + process.env.GIT_EDITOR = `"${process.execPath}" "${script}"`; + t.after(() => { + if (previous === undefined) delete process.env.GIT_EDITOR; + else process.env.GIT_EDITOR = previous; + }); + } + + const normalized = JSON.stringify({ + releaseDate: '2026-10-06', reports: [], dependencies: {} + }, null, 2) + '\n'; + + it('returns the edited content, normalized', async(t) => { + useEditor(t, ['{"releaseDate":"2026/10/06","reports":[]}']); + const release = new PrepareSecurityRelease({}); + assert.strictEqual(await release.editVulnerabilitiesJSON('{}\n'), normalized); + }); + + it('offers to edit again when the result is not valid', async(t) => { + useEditor(t, [ + '{', + '{"releaseDate":"2026/10/06"}', + '{"releaseDate":"2026/10/06","reports":[]}' + ]); + const prompts = []; + const release = new PrepareSecurityRelease({ + error() {}, + prompt(message) { + prompts.push(message); + return true; + } + }); + assert.strictEqual(await release.editVulnerabilitiesJSON('{}\n'), normalized); + assert.deepStrictEqual(prompts, [ + 'Edit vulnerabilities.json again?', + 'Edit vulnerabilities.json again?' + ]); + }); + + it('aborts when the user does not fix invalid JSON', async(t) => { + useEditor(t, ['{']); + const release = new PrepareSecurityRelease({ + error() {}, + prompt() { + return false; + } + }); + await assert.rejects(() => release.editVulnerabilitiesJSON('{}\n'), + /vulnerabilities\.json is not valid/); + }); +}); + describe('security_blog: pre-release severity wording', () => { it('does not include severity counts in the summary', () => { const blog = new SecurityBlog();