From d8fe994ca7c20eddaf57de984988635536168e27 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 14 Jun 2026 23:21:10 -0700 Subject: [PATCH] FN-6472: make release dry-runs non-interactive by default Make dry-run release previews skip stdin prompts unless explicitly requested. - Add a release prompt gate that suppresses version prompts for default dry-runs and honors --interactive as an opt-in. - Update release CLI usage text and argument parsing for dry-run interactivity. - Cover dry-run, --yes, real-release, and release.mjs ordering behavior with script tests. Files changed: scripts/__tests__/release-prompt-gate.test.mjs | 72 ++++++++++++++++++++++++++ scripts/lib/release-prompt-gate.mjs | 13 +++++ scripts/release.mjs | 16 ++++-- 3 files changed, 97 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6472 Fusion-Task-Lineage: 07bfd944-f4a0-454a-883f-dadf1aa0b7d4 --- .../__tests__/release-prompt-gate.test.mjs | 72 +++++++++++++++++++ scripts/lib/release-prompt-gate.mjs | 13 ++++ scripts/release.mjs | 16 +++-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 scripts/__tests__/release-prompt-gate.test.mjs create mode 100644 scripts/lib/release-prompt-gate.mjs diff --git a/scripts/__tests__/release-prompt-gate.test.mjs b/scripts/__tests__/release-prompt-gate.test.mjs new file mode 100644 index 0000000000..620b248a2c --- /dev/null +++ b/scripts/__tests__/release-prompt-gate.test.mjs @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { URL } from "node:url"; + +import { shouldPromptForVersion } from "../lib/release-prompt-gate.mjs"; + +test("dry-run is non-interactive by default for the FN-6469 no-TTY path", () => { + assert.equal( + shouldPromptForVersion({ dryRun: true, autoYes: false, interactive: false }), + false, + ); +}); + +test("dry-run interactive override exercises the version prompt", () => { + assert.equal( + shouldPromptForVersion({ dryRun: true, autoYes: false, interactive: true }), + true, + ); +}); + +test("dry-run --yes never prompts regardless of interactive flag", () => { + assert.equal( + shouldPromptForVersion({ dryRun: true, autoYes: true, interactive: false }), + false, + ); + assert.equal( + shouldPromptForVersion({ dryRun: true, autoYes: true, interactive: true }), + false, + ); +}); + +test("real releases prompt unless --yes is passed", () => { + assert.equal( + shouldPromptForVersion({ dryRun: false, autoYes: false, interactive: false }), + true, + ); + assert.equal( + shouldPromptForVersion({ dryRun: false, autoYes: false, interactive: true }), + true, + ); + assert.equal( + shouldPromptForVersion({ dryRun: false, autoYes: true, interactive: false }), + false, + ); + assert.equal( + shouldPromptForVersion({ dryRun: false, autoYes: true, interactive: true }), + false, + ); +}); + +test("prompt decision is independent of representative version values", () => { + const representativeVersions = ["0.43.1", "1.0.0", "2.0.0-beta.1"]; + const decisions = representativeVersions.map(() => + shouldPromptForVersion({ dryRun: true, autoYes: false, interactive: false }), + ); + + assert.deepEqual(decisions, [false, false, false]); +}); + +test("release script dry-run exits before proceed confirmation and gates ask through helper", () => { + const source = readFileSync(new URL("../release.mjs", import.meta.url), "utf8"); + const promptGateIndex = source.indexOf("shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: INTERACTIVE })"); + const askIndex = source.indexOf("await ask(`Release version"); + const dryRunExitIndex = source.indexOf("if (DRY_RUN) {"); + const confirmIndex = source.indexOf("await confirm(`Proceed with release"); + + assert.notEqual(promptGateIndex, -1, "release.mjs should use the pure prompt gate"); + assert.notEqual(askIndex, -1, "release.mjs should still support version prompts"); + assert.ok(promptGateIndex < askIndex, "ask() must be guarded by shouldPromptForVersion()"); + assert.ok(dryRunExitIndex < confirmIndex, "dry-run must exit before proceed confirmation"); +}); diff --git a/scripts/lib/release-prompt-gate.mjs b/scripts/lib/release-prompt-gate.mjs new file mode 100644 index 0000000000..e6585f6e5a --- /dev/null +++ b/scripts/lib/release-prompt-gate.mjs @@ -0,0 +1,13 @@ +/** + * FNXC:ReleaseScript 2026-06-14-23:08: + * Dry-run releases must be non-interactive by default because FN-6469 showed non-TTY agent shells can hang on unsettled top-level await and exit 13 when the version prompt reads stdin. + * `--interactive` is the explicit dry-run opt-in for maintainers who intentionally want to exercise the version prompt; real releases keep prompting unless `--yes` is passed. + * + * @param {{ dryRun: boolean, autoYes: boolean, interactive: boolean }} options + * @returns {boolean} true when the release script should prompt for a version override. + */ +export function shouldPromptForVersion({ dryRun, autoYes, interactive }) { + if (autoYes) return false; + if (dryRun) return interactive; + return true; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 36550e8264..94d1f61493 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -12,9 +12,11 @@ // - `npm login` already completed (publish uses the active npm token) // // Usage: -// pnpm release # interactive: review changesets, accept or override version, confirm -// pnpm release --yes # accept the proposed version, skip confirmation prompt -// pnpm release --dry-run # preview only — exit before any file/git/npm changes +// pnpm release # interactive: review changesets, accept or override version, confirm +// pnpm release --yes # accept the proposed version, skip confirmation prompt +// pnpm release --dry-run # preview only; non-interactive by default; no file/git/npm changes +// pnpm release --dry-run --interactive +// # preview only, but exercise the version prompt override import { spawnSync } from "node:child_process"; import { readFileSync, readdirSync, writeFileSync, statSync, existsSync, unlinkSync, mkdtempSync, rmSync } from "node:fs"; @@ -24,10 +26,16 @@ import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { extractVersionNotes } from "./lib/extract-version-notes.mjs"; +import { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs"; const args = new Set(process.argv.slice(2)); +/* + * FNXC:ReleaseScript 2026-06-14-23:08: + * `--dry-run` must not read stdin in the default agent-shell path; `--interactive` is the explicit maintainer override for prompt coverage while preserving real-release prompts. + */ const DRY_RUN = args.has("--dry-run"); const AUTO_YES = args.has("--yes") || args.has("-y"); +const INTERACTIVE = args.has("--interactive"); const color = (c, s) => `\x1b[${c}m${s}\x1b[0m`; const info = (s) => console.log(color(36, "▶ ") + s); @@ -506,7 +514,7 @@ console.log(` Bumped packages : ${releases.map((r) => r.name).join(", ")}`); console.log(""); let chosenVersion = proposedVersion; -if (!AUTO_YES) { +if (shouldPromptForVersion({ dryRun: DRY_RUN, autoYes: AUTO_YES, interactive: INTERACTIVE })) { while (true) { const answer = await ask(`Release version [${proposedVersion}]: `); if (answer === "") break;