Files
fusion/scripts/__tests__/release-authorization-gate.test.mjs
gsxdsm 81188f1996 FN-6480: require operator authorization for releases
Require an out-of-repository operator signal before the release script can mutate version, publish, push, or tag.

- Add a reusable release authorization gate that allows dry-runs and blocks real releases without FUSION_RELEASE_AUTHORIZED.
- Invoke the gate in scripts/release.mjs before the first release mutation while preserving dry-run behavior.
- Cover blocked, authorized, dry-run, whitespace, TTY, and call-order behavior with script tests.

Files changed:
 .../__tests__/release-authorization-gate.test.mjs  | 70 ++++++++++++++++++++++
 scripts/lib/release-authorization-gate.mjs         | 30 ++++++++++
 scripts/release.mjs                                | 26 +++++++-
 3 files changed, 123 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6480

Fusion-Task-Lineage: 5347552c-e395-4852-b389-6bbdba0e044e
2026-06-16 15:08:24 -07:00

71 lines
3.0 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { URL } from "node:url";
import {
evaluateReleaseAuthorization,
RELEASE_AUTHORIZATION_ENV,
} 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 });
assert.equal(result.authorized, false);
assert.equal(result.mode, "blocked");
assert.match(result.reason ?? "", /non-interactive shell/);
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,
});
assert.deepEqual(result, { authorized: true, mode: "env-signal" });
});
test("dry-run bypasses authorization because it publishes nothing", () => {
const result = evaluateReleaseAuthorization({ dryRun: true, env: {}, 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,
});
assert.equal(result.authorized, false, `expected ${JSON.stringify(value)} to be blocked`);
assert.equal(result.mode, "blocked");
}
});
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", () => {
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 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(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");
});