diff --git a/lib/cli.js b/lib/cli.js index dfb6cc75..e1b921af 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -102,6 +102,20 @@ export default class CLI { return inquirer.checkbox({ message, choices }); } + async promptSelect(message, choices, opts = {}) { + const defaultAnswer = opts.defaultAnswer ?? choices[0]?.value; + + if (this.assumeYes) { + return defaultAnswer; + } + + return inquirer.select({ + message, + choices, + default: defaultAnswer + }); + } + setAssumeYes() { this.assumeYes = true; } diff --git a/lib/prepare_security.js b/lib/prepare_security.js index 8bddc1cd..4342d881 100644 --- a/lib/prepare_security.js +++ b/lib/prepare_security.js @@ -25,6 +25,62 @@ function relativeDate(date) { return months === 1 ? '1 month ago' : `${months} months ago`; } +function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}/${month}/${day}`; +} + +function formatDaysFromNow(days) { + return days === 1 ? 'in 1 day' : `in ${days} days`; +} + +function getNextTuesdayReleaseDateObjects(fromDate = new Date(), count = 4) { + const TUESDAY = 2; + const start = new Date( + fromDate.getFullYear(), + fromDate.getMonth(), + fromDate.getDate() + ); + const daysUntilTuesday = (TUESDAY - start.getDay() + 7) % 7 || 7; + start.setDate(start.getDate() + daysUntilTuesday); + + return Array.from({ length: count }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + (index * 7)); + return { + date, + daysFromNow: daysUntilTuesday + (index * 7) + }; + }); +} + +export function getNextTuesdayReleaseDates(fromDate = new Date(), count = 4) { + return getNextTuesdayReleaseDateObjects(fromDate, count) + .map(({ date }) => formatDate(date)); +} + +export function getNextTuesdayReleaseDateChoices(fromDate = new Date(), count = 4) { + const choices = getNextTuesdayReleaseDateObjects(fromDate, count) + .map(({ date, daysFromNow }) => { + const formattedDate = formatDate(date); + return { + name: formattedDate, + value: formattedDate, + description: `Tuesday, ${formatDaysFromNow(daysFromNow)}` + }; + }); + + choices.push({ + name: 'TBD', + value: 'TBD', + description: 'Release date not defined yet' + }); + + return choices; +} + export default class PrepareSecurityRelease extends SecurityRelease { title = 'Next Security Release'; @@ -182,15 +238,12 @@ export default class PrepareSecurityRelease extends SecurityRelease { } async promptReleaseDate() { - const nextWeekDate = new Date(); - nextWeekDate.setDate(nextWeekDate.getDate() + 7); - // Format the date as YYYY/MM/DD - const formattedDate = nextWeekDate.toISOString().slice(0, 10).replace(/-/g, '/'); - return this.cli.prompt( - 'Enter target release date in YYYY/MM/DD format (TBD if not defined yet):', { - questionType: 'input', - defaultAnswer: formattedDate - }); + const choices = getNextTuesdayReleaseDateChoices(); + return this.cli.promptSelect( + 'Select target release date:', + choices, + { defaultAnswer: choices[0].value } + ); } async promptVulnerabilitiesJSON() { diff --git a/test/unit/security_permissions.test.js b/test/unit/security_permissions.test.js new file mode 100644 index 00000000..8d45937e --- /dev/null +++ b/test/unit/security_permissions.test.js @@ -0,0 +1,42 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +import { confirmSecurityStep } from '../../lib/security-release/security-release.js'; + +describe('security release permissions', () => { + it('asks permission for an individual step and defaults to no', async() => { + const cli = { + promptCalls: [], + prompt(message, options) { + this.promptCalls.push([message, options]); + return true; + } + }; + + await confirmSecurityStep( + cli, + 'run `git push -u origin next-security-release`', + 'This pushes the security release branch.' + ); + + assert.deepStrictEqual(cli.promptCalls, [[ + 'Allow action: run `git push -u origin next-security-release`?\n\n' + + 'This pushes the security release branch.', + { defaultAnswer: false } + ]]); + }); + + it('aborts when an individual step is denied', async() => { + const cli = { + prompt() { + return false; + } + }; + + await assert.rejects(() => confirmSecurityStep( + cli, + 'write `security-release/next-security-release/vulnerabilities.json`', + 'This writes vulnerabilities.json.' + ), /Aborted: write `security-release\/next-security-release\/vulnerabilities\.json`\./); + }); +}); diff --git a/test/unit/security_release.test.js b/test/unit/security_release.test.js index 8058e8be..67ea65b6 100644 --- a/test/unit/security_release.test.js +++ b/test/unit/security_release.test.js @@ -2,6 +2,10 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; import SecurityBlog from '../../lib/security_blog.js'; +import PrepareSecurityRelease, { + getNextTuesdayReleaseDateChoices, + getNextTuesdayReleaseDates +} from '../../lib/prepare_security.js'; import { getHighestSeverityAnnouncement } from '../../lib/security-release/security-release.js'; @@ -91,6 +95,61 @@ describe('security_release: severity announcement', () => { }); }); +describe('security_release: release date choices', () => { + it('starts with the next Tuesday when today is Tuesday', () => { + assert.deepStrictEqual( + getNextTuesdayReleaseDates(new Date(2026, 6, 7), 4), + ['2026/07/14', '2026/07/21', '2026/07/28', '2026/08/04'] + ); + }); + + it('uses the upcoming Tuesday when today is before Tuesday', () => { + assert.deepStrictEqual( + getNextTuesdayReleaseDates(new Date(2026, 6, 8), 3), + ['2026/07/14', '2026/07/21', '2026/07/28'] + ); + }); + + it('describes upcoming Tuesdays with relative timing', () => { + assert.deepStrictEqual( + getNextTuesdayReleaseDateChoices(new Date(2026, 6, 7), 2), + [ + { + name: '2026/07/14', + value: '2026/07/14', + description: 'Tuesday, in 7 days' + }, + { + name: '2026/07/21', + value: '2026/07/21', + description: 'Tuesday, in 14 days' + }, + { + name: 'TBD', + value: 'TBD', + description: 'Release date not defined yet' + } + ] + ); + }); + + it('prompts with upcoming Tuesdays and TBD', async() => { + const release = new PrepareSecurityRelease({ + promptSelect(message, choices, options) { + assert.strictEqual(message, 'Select target release date:'); + assert.deepStrictEqual( + choices.map(({ value }) => value), + [...getNextTuesdayReleaseDates(), 'TBD'] + ); + assert.strictEqual(options.defaultAnswer, choices[0].value); + return choices[0].value; + } + }); + + assert.strictEqual(await release.promptReleaseDate(), getNextTuesdayReleaseDates()[0]); + }); +}); + describe('security_blog: pre-release severity wording', () => { it('does not include severity counts in the summary', () => { const blog = new SecurityBlog();