diff --git a/scripts/__tests__/release-authorization-gate.test.mjs b/scripts/__tests__/release-authorization-gate.test.mjs index c9bc532324..ff1df629d5 100644 --- a/scripts/__tests__/release-authorization-gate.test.mjs +++ b/scripts/__tests__/release-authorization-gate.test.mjs @@ -5,66 +5,68 @@ import { URL } from "node:url"; import { evaluateReleaseAuthorization, - RELEASE_AUTHORIZATION_ENV, + isReleaseAuthorizationPhrase, + RELEASE_AUTHORIZATION_PHRASE, } from "../lib/release-authorization-gate.mjs"; -test("gate blocks real release without signal in non-interactive FN-6469 path", () => { - const result = evaluateReleaseAuthorization({ dryRun: false, env: {}, stdinIsTTY: false }); +test("gate blocks a real release run non-interactively (no TTY)", () => { + const result = evaluateReleaseAuthorization({ dryRun: false, stdinIsTTY: false }); assert.equal(result.authorized, false); assert.equal(result.mode, "blocked"); - assert.match(result.reason ?? "", /non-interactive shell/); + assert.match(result.reason ?? "", /non-interactively/); assert.match(result.reason ?? "", /aborted before version bump, publish, push, or tag/); }); -test("gate allows real release with explicit operator signal", () => { - const result = evaluateReleaseAuthorization({ - dryRun: false, - env: { [RELEASE_AUTHORIZATION_ENV]: "operator-held-one-time-approval" }, - stdinIsTTY: false, - }); +test("interactive real release requires the typed authorization phrase", () => { + const result = evaluateReleaseAuthorization({ dryRun: false, stdinIsTTY: true }); - assert.deepEqual(result, { authorized: true, mode: "env-signal" }); + assert.deepEqual(result, { authorized: false, mode: "requires-confirmation" }); }); test("dry-run bypasses authorization because it publishes nothing", () => { - const result = evaluateReleaseAuthorization({ dryRun: true, env: {}, stdinIsTTY: false }); + const result = evaluateReleaseAuthorization({ dryRun: true, stdinIsTTY: false }); assert.deepEqual(result, { authorized: true, mode: "dry-run-bypass" }); }); -test("empty or whitespace-only authorization signal fails closed", () => { - for (const value of ["", " ", "\n\t"]) { - const result = evaluateReleaseAuthorization({ - dryRun: false, - env: { [RELEASE_AUTHORIZATION_ENV]: value }, - stdinIsTTY: false, - }); +test("only the exact authorization phrase passes; anything else fails closed", () => { + assert.equal(isReleaseAuthorizationPhrase(RELEASE_AUTHORIZATION_PHRASE), true); + assert.equal(isReleaseAuthorizationPhrase(" Authorized "), true); + assert.equal(isReleaseAuthorizationPhrase("AUTHORIZED\n"), true); - assert.equal(result.authorized, false, `expected ${JSON.stringify(value)} to be blocked`); - assert.equal(result.mode, "blocked"); + for (const value of ["", " ", "yes", "y", "authorize", "authorized now", undefined, null]) { + assert.equal( + isReleaseAuthorizationPhrase(value), + false, + `expected ${JSON.stringify(value)} to be rejected`, + ); } }); -test("TTY presence alone does not authorize a real release", () => { - const result = evaluateReleaseAuthorization({ dryRun: false, env: {}, stdinIsTTY: true }); - - assert.equal(result.authorized, false); - assert.equal(result.mode, "blocked"); - assert.match(result.reason ?? "", /interactive shell/); -}); - -test("release script imports and enforces the authorization gate after dry-run exit", () => { +test("release script prompts for the authorization phrase before the first mutation", () => { const source = readFileSync(new URL("../release.mjs", import.meta.url), "utf8"); const importIndex = source.indexOf("./lib/release-authorization-gate.mjs"); const dryRunExitIndex = source.indexOf("if (DRY_RUN) {"); const gateIndex = source.indexOf("evaluateReleaseAuthorization({"); + const phraseCheckIndex = source.indexOf("isReleaseAuthorizationPhrase("); const versionBumpIndex = source.indexOf("run(\"pnpm release:version\")"); assert.notEqual(importIndex, -1, "release.mjs should import the authorization helper"); assert.notEqual(dryRunExitIndex, -1, "release.mjs should retain the dry-run early exit"); assert.notEqual(gateIndex, -1, "release.mjs should call evaluateReleaseAuthorization()"); + assert.notEqual(phraseCheckIndex, -1, "release.mjs should validate the typed authorization phrase"); assert.notEqual(versionBumpIndex, -1, "release.mjs should still run the version bump after gates"); assert.ok(dryRunExitIndex < gateIndex, "dry-run must exit before the authorization gate call site"); - assert.ok(gateIndex < versionBumpIndex, "authorization must be checked before the first mutation"); + assert.ok(gateIndex < phraseCheckIndex, "the gate decision must precede the typed-phrase check"); + assert.ok(phraseCheckIndex < versionBumpIndex, "authorization must be checked before the first mutation"); +}); + +test("env vars no longer influence release authorization", () => { + const source = readFileSync( + new URL("../lib/release-authorization-gate.mjs", import.meta.url), + "utf8", + ); + assert.ok(!/FUSION_RELEASE_AUTHORIZED/.test(source), "the env signal must be fully removed"); + assert.ok(!/process\.env/.test(source), "the gate must not read process env"); }); diff --git a/scripts/lib/release-authorization-gate.mjs b/scripts/lib/release-authorization-gate.mjs index df99c09a4b..f0b57a8e52 100644 --- a/scripts/lib/release-authorization-gate.mjs +++ b/scripts/lib/release-authorization-gate.mjs @@ -1,30 +1,51 @@ -export const RELEASE_AUTHORIZATION_ENV = "FUSION_RELEASE_AUTHORIZED"; +/** + * FNXC:ReleaseScript 2026-07-08-11:20: + * The former operator-held environment signal is removed. An env var is self-grantable (an agent can `export` it) and survives into non-interactive shells, so it never proved a live human. Real releases now require a human to type the authorization phrase at an interactive prompt, and a release can no longer be run non-interactively at all. + * + * FN-6469 proved that branch and working-tree preflight checks are not an authorization boundary because an agent can clone `main` into a fresh directory and rerun `pnpm release --yes`. The interactive-typed phrase closes that hole: it cannot be satisfied without a TTY-attached human, and `--yes` does not bypass it. + */ /** - * FNXC:ReleaseScript 2026-06-15-02:41: - * FN-6469 proved that branch and working-tree preflight checks are not an authorization boundary because an agent can clone `main` into a fresh directory and rerun `pnpm release --yes`. - * Real releases are not agent-initiable: the publish path requires an explicit operator-held environment signal that is outside repo state and cannot be self-granted by reproducing `main`; dry-runs bypass this gate because they publish nothing. - * - * @param {{ dryRun: boolean, env?: Record, stdinIsTTY?: boolean }} options - * @returns {{ authorized: boolean, mode: "dry-run-bypass" | "env-signal" | "blocked", reason?: string }} + * The exact phrase an operator must type to authorize a real release. + * Matched case-insensitively after trimming surrounding whitespace. */ -export function evaluateReleaseAuthorization({ dryRun, env = {}, stdinIsTTY = false }) { +export const RELEASE_AUTHORIZATION_PHRASE = "authorized"; + +/** + * FNXC:ReleaseScript 2026-07-08-11:20: + * Pure decision for how release authorization must be obtained. Real releases require a live operator: dry-runs publish nothing and bypass; a non-interactive shell (no TTY) is blocked outright because the authorization phrase cannot be typed; an interactive shell must prompt for the typed phrase (see {@link isReleaseAuthorizationPhrase}). + * + * @param {{ dryRun: boolean, stdinIsTTY?: boolean }} options + * @returns {{ authorized: boolean, mode: "dry-run-bypass" | "requires-confirmation" | "blocked", reason?: string }} + */ +export function evaluateReleaseAuthorization({ dryRun, stdinIsTTY = false }) { if (dryRun === true) { return { authorized: true, mode: "dry-run-bypass" }; } - const signal = env[RELEASE_AUTHORIZATION_ENV]; - if (typeof signal === "string" && signal.trim() !== "") { - return { authorized: true, mode: "env-signal" }; + if (stdinIsTTY !== true) { + return { + authorized: false, + mode: "blocked", + reason: + "Releases cannot be run non-interactively: no TTY is attached, so the operator authorization phrase cannot be typed. " + + `Run \`pnpm release\` from an interactive terminal and type "${RELEASE_AUTHORIZATION_PHRASE}" when prompted; aborted before version bump, publish, push, or tag.`, + }; } - const shellContext = stdinIsTTY - ? "No operator authorization signal was present in this interactive shell." - : "No operator authorization signal was present in this non-interactive shell."; - - return { - authorized: false, - mode: "blocked", - reason: `${shellContext} Real releases require explicit operator authorization via ${RELEASE_AUTHORIZATION_ENV}; aborted before version bump, publish, push, or tag.`, - }; + return { authorized: false, mode: "requires-confirmation" }; +} + +/** + * FNXC:ReleaseScript 2026-07-08-11:20: + * Validate what the operator typed at the authorization prompt. Only the exact phrase (case-insensitive, whitespace-trimmed) authorizes the release; anything else fails closed. + * + * @param {unknown} input raw string the operator typed + * @returns {boolean} + */ +export function isReleaseAuthorizationPhrase(input) { + return ( + typeof input === "string" && + input.trim().toLowerCase() === RELEASE_AUTHORIZATION_PHRASE + ); } diff --git a/scripts/release.mjs b/scripts/release.mjs index e556f26126..0cf888980a 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -10,13 +10,14 @@ // - clean working tree on `main`, up to date with origin // - at least one pending changeset in .changeset/ // - `npm login` already completed (publish uses the active npm token) -// - real releases require an operator-held FUSION_RELEASE_AUTHORIZED signal; -// dry-runs do not require it because they make no file/git/npm changes +// - real releases require a live operator to type the authorization phrase +// ("authorized") at an interactive prompt; they cannot run non-interactively. +// Dry-runs skip this because they make no file/git/npm changes // // Usage: -// pnpm release # interactive: review changesets, accept or override version, confirm, then require operator authorization before mutation -// pnpm release --yes # accept the proposed version, skip confirmation prompt, still require operator authorization before mutation -// pnpm release --dry-run # preview only; non-interactive by default; no authorization signal or file/git/npm changes +// pnpm release # interactive: review changesets, accept or override version, type the authorization phrase, then confirm before mutation +// pnpm release --yes # accept the proposed version, skip the y/N confirmation prompt, but STILL require the typed authorization phrase before mutation +// pnpm release --dry-run # preview only; non-interactive by default; no authorization or file/git/npm changes // pnpm release --dry-run --interactive // # preview only, but exercise the version prompt override @@ -27,7 +28,11 @@ import { tmpdir } from "node:os"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; -import { evaluateReleaseAuthorization } from "./lib/release-authorization-gate.mjs"; +import { + evaluateReleaseAuthorization, + isReleaseAuthorizationPhrase, + RELEASE_AUTHORIZATION_PHRASE, +} from "./lib/release-authorization-gate.mjs"; import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs"; import { parseChangesetFile } from "./lib/changeset-schema.mjs"; import { distillDeterministic } from "./lib/distill-release-notes.mjs"; @@ -543,21 +548,29 @@ if (DRY_RUN) { } /* - * FNXC:ReleaseScript 2026-06-15-02:45: - * FN-6469 showed `main`-branch preflight is bypassable by cloning a clean `main`; require an out-of-tree operator-held authorization signal before any version bump, publish, push, tag, GitHub Release, or Homebrew tap mutation can begin. - * Dry-run exits above so agents can still inspect release plans without the signal. + * FNXC:ReleaseScript 2026-07-08-11:20: + * FN-6469 showed `main`-branch preflight is bypassable by cloning a clean `main`. A real release now requires a live human to type the authorization phrase at an interactive prompt before any version bump, publish, push, tag, GitHub Release, or Homebrew tap mutation can begin. This replaces the removed `FUSION_RELEASE_AUTHORIZED` env signal, which was self-grantable and leaked into non-interactive shells. `--yes` does not bypass this prompt; a non-interactive shell is blocked outright. Dry-run exits above so agents can still inspect release plans without authorization. */ const releaseAuthorization = evaluateReleaseAuthorization({ dryRun: DRY_RUN, - env: process.env, stdinIsTTY: process.stdin.isTTY === true, }); -if (!releaseAuthorization.authorized) { +if (releaseAuthorization.mode === "blocked") { fail( `${releaseAuthorization.reason ?? "Release is not authorized."}\n` + - "Releases are not agent-initiable. A human operator must provide the operator-held FUSION_RELEASE_AUTHORIZED signal from outside the repository before invoking a real release.", + "Releases are not agent-initiable and cannot run non-interactively.", ); } +if (releaseAuthorization.mode === "requires-confirmation") { + const typed = await ask( + `Type "${RELEASE_AUTHORIZATION_PHRASE}" to authorize this real release (build, publish, tag): `, + ); + if (!isReleaseAuthorizationPhrase(typed)) { + fail( + `Authorization phrase not entered ("${RELEASE_AUTHORIZATION_PHRASE}" required); aborted before version bump, publish, push, or tag.`, + ); + } +} if (!(await confirm(`Proceed with release v${chosenVersion} (build, publish, tag)?`))) { warn("Aborted by user.");