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
This commit is contained in:
gsxdsm
2026-06-14 23:21:10 -07:00
parent 830bc7a67c
commit d8fe994ca7
3 changed files with 97 additions and 4 deletions

View File

@@ -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");
});

View File

@@ -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;
}

View File

@@ -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;