diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index 08db3d14bbe..eef88f2f2af 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -2,13 +2,8 @@ // Checks or refreshes generated release artifacts before a release publish. import { runManagedCommand } from "./lib/managed-child-process.mjs"; -const args = new Set(process.argv.slice(2)); -const fix = args.has("--fix"); - -if (fix && args.has("--check")) { - console.error("Use either --fix or --check, not both."); - process.exit(1); -} +const parsedArgs = parseArgs(process.argv.slice(2)); +const fix = parsedArgs.fix; const fixCommands = [ { name: "plugin versions", args: ["plugins:sync"] }, @@ -96,3 +91,37 @@ function printFailures(title, failures) { console.error(`- ${failure.name}: exit ${failure.status} (pnpm ${failure.args.join(" ")})`); } } + +function parseArgs(argv) { + let check = false; + let wantsFix = false; + for (const arg of argv) { + if (arg === "--help") { + printUsage(console.log); + process.exit(0); + } + if (arg === "--check") { + check = true; + continue; + } + if (arg === "--fix") { + wantsFix = true; + continue; + } + console.error(`Unknown release preflight argument: ${arg}`); + printUsage(console.error); + process.exit(1); + } + if (wantsFix && check) { + console.error("Use either --fix or --check, not both."); + process.exit(1); + } + return { fix: wantsFix }; +} + +function printUsage(writeLine) { + writeLine("Usage: node scripts/release-preflight.mjs [--check|--fix]"); + writeLine(""); + writeLine(" --check verify generated release artifacts without writing changes (default)"); + writeLine(" --fix refresh generated release artifacts, then verify them"); +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 954c358fa36..d8bbefee4b5 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1246,6 +1246,7 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/lib/plugin-package-dependencies.mjs", ["test/scripts/plugin-package-dependencies.test.ts"], ], + ["scripts/release-preflight.mjs", ["test/scripts/release-preflight.test.ts"]], [ "scripts/lib/plugin-npm-runtime-assets.mjs", ["test/scripts/plugin-npm-runtime-build-args.test.ts"], diff --git a/test/scripts/release-preflight.test.ts b/test/scripts/release-preflight.test.ts new file mode 100644 index 00000000000..1bf6615e35f --- /dev/null +++ b/test/scripts/release-preflight.test.ts @@ -0,0 +1,126 @@ +// Release preflight tests keep generated-artifact checks fail-closed for operators. +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { afterEach, describe, expect, it } from "vitest"; +import { cleanupTempDirs, makeTempDir } from "../helpers/temp-dir.js"; + +const SCRIPT = "scripts/release-preflight.mjs"; +const CHECK_COMMANDS = [ + "deps:root-ownership:check", + "deps:shrinkwrap:check", + "plugins:sync:check", + "plugins:inventory:check", + "config:schema:check", + "config:channels:check", + "config:docs:check", + "plugin-sdk:check-exports", + "plugin-sdk:api:check", + "plugin-sdk:surface:check", +]; +const FIX_COMMANDS = [ + "plugins:sync", + "deps:shrinkwrap:changed:generate", + "plugins:inventory:gen", + "config:schema:gen", + "config:channels:gen", + "config:docs:gen", + "plugin-sdk:sync-exports", + "plugin-sdk:api:gen", +]; + +const tempDirs = new Set(); + +afterEach(() => { + cleanupTempDirs(tempDirs); +}); + +function makeFakePnpm(): { binDir: string; logPath: string } { + const root = makeTempDir(tempDirs, "openclaw-release-preflight-"); + const binDir = join(root, "bin"); + const logPath = join(root, "pnpm.log"); + mkdirSync(binDir); + const pnpmPath = join(binDir, "pnpm"); + writeFileSync( + pnpmPath, + `#!/usr/bin/env node +import { appendFileSync } from "node:fs"; + +const command = process.argv.slice(2).join(" "); +appendFileSync(process.env.OPENCLAW_RELEASE_PREFLIGHT_PNPM_LOG, command + "\\n"); +const failures = new Set((process.env.OPENCLAW_RELEASE_PREFLIGHT_FAIL_COMMANDS ?? "").split(";").filter(Boolean)); +process.exit(failures.has(command) ? 7 : 0); +`, + { mode: 0o755 }, + ); + chmodSync(pnpmPath, 0o755); + return { binDir, logPath }; +} + +function runPreflight( + args: string[], + fakePnpm?: ReturnType, + extraEnv: NodeJS.ProcessEnv = {}, +) { + return spawnSync(process.execPath, [SCRIPT, ...args], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + ...(fakePnpm + ? { + OPENCLAW_RELEASE_PREFLIGHT_PNPM_LOG: fakePnpm.logPath, + PATH: `${fakePnpm.binDir}${delimiter}${process.env.PATH ?? ""}`, + } + : {}), + }, + }); +} + +function readPnpmLog(logPath: string): string[] { + return readFileSync(logPath, "utf8").trimEnd().split("\n").filter(Boolean); +} + +describe("scripts/release-preflight.mjs", () => { + it("rejects unknown arguments before running release checks", () => { + const result = runPreflight(["--fiix"]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unknown release preflight argument: --fiix"); + expect(result.stderr).toContain("Usage: node scripts/release-preflight.mjs [--check|--fix]"); + expect(result.stdout).toBe(""); + }); + + it("runs every check command and reports all failed release artifact checks", () => { + const fakePnpm = makeFakePnpm(); + const result = runPreflight(["--check"], fakePnpm, { + OPENCLAW_RELEASE_PREFLIGHT_FAIL_COMMANDS: "plugins:sync:check;config:docs:check", + }); + + expect(result.status).toBe(1); + expect(readPnpmLog(fakePnpm.logPath)).toEqual(CHECK_COMMANDS); + expect(result.stderr).toContain("- plugin versions: exit 7 (pnpm plugins:sync:check)"); + expect(result.stderr).toContain("- config docs baseline: exit 7 (pnpm config:docs:check)"); + }); + + it("stops refresh mode at the first failed generator before running checks", () => { + const fakePnpm = makeFakePnpm(); + const result = spawnSync(process.execPath, [SCRIPT, "--fix"], { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_RELEASE_PREFLIGHT_FAIL_COMMANDS: "deps:shrinkwrap:changed:generate", + OPENCLAW_RELEASE_PREFLIGHT_PNPM_LOG: fakePnpm.logPath, + PATH: `${fakePnpm.binDir}${delimiter}${process.env.PATH ?? ""}`, + }, + }); + + expect(result.status).toBe(1); + expect(readPnpmLog(fakePnpm.logPath)).toEqual(FIX_COMMANDS.slice(0, 2)); + expect(result.stderr).toContain( + "- npm shrinkwraps: exit 7 (pnpm deps:shrinkwrap:changed:generate)", + ); + }); +});