diff --git a/scripts/plugin-release-pretag-pack-check.ts b/scripts/plugin-release-pretag-pack-check.ts index be97ddcbd85e..6a275060433a 100644 --- a/scripts/plugin-release-pretag-pack-check.ts +++ b/scripts/plugin-release-pretag-pack-check.ts @@ -1,14 +1,17 @@ #!/usr/bin/env -S node --import tsx -import { execFileSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { hasUnjoinedWork, runManagedCommand } from "./lib/managed-child-process.mts"; import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts"; import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts"; const DEFAULT_CLAWHUB_CLI_PACKAGE = "clawhub@0.23.3"; +// Match the existing npm release-check and shrinkwrap command 10-minute ceilings: complete +// plugin builds and packs retain their normal budget while lifecycle or registry stalls stop. +const PLUGIN_RELEASE_PRETAG_COMMAND_TIMEOUT_MS = 10 * 60_000; type PluginReleasePretagPackTarget = { packageDir: string; @@ -17,6 +20,21 @@ type PluginReleasePretagPackTarget = { packNpm: boolean; }; +/** Preserve conventional managed-command exit statuses at the executable boundary. */ +export function pluginReleasePretagExitCode(error: unknown): number { + if ( + error instanceof Error && + "code" in error && + typeof error.code === "number" && + Number.isInteger(error.code) && + error.code >= 0 && + error.code <= 255 + ) { + return error.code; + } + return 1; +} + export function collectPluginReleasePretagPackTargets( rootDir = resolve("."), ): PluginReleasePretagPackTarget[] { @@ -45,19 +63,52 @@ export function collectPluginReleasePretagPackTargets( ); } -function runCommand( +async function runCommand( command: string, args: string[], - params: { cwd: string; env?: NodeJS.ProcessEnv; quietStdout?: boolean }, -) { - execFileSync(command, args, { - cwd: params.cwd, - env: params.env ?? process.env, - stdio: params.quietStdout ? ["inherit", "ignore", "inherit"] : "inherit", - }); + params: { + commandLabel: string; + cwd: string; + env?: NodeJS.ProcessEnv; + quietStdout?: boolean; + stage: string; + timeoutMs: number; + }, +): Promise { + let status: number; + try { + status = await runManagedCommand({ + args, + bin: command, + cwd: params.cwd, + env: params.env, + shell: false, + requireProcessTreeExit: process.platform !== "win32", + stdio: params.quietStdout ? ["inherit", "ignore", "inherit"] : "inherit", + timeoutMs: params.timeoutMs, + }); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ETIMEDOUT")) { + throw error; + } + throw Object.assign( + new Error(`${params.stage} timed out after ${params.timeoutMs}ms: ${params.commandLabel}`), + { code: "ETIMEDOUT" as const }, + ); + } + if (status !== 0) { + throw Object.assign( + new Error(`${params.stage} failed with exit code ${status}: ${params.commandLabel}`), + { code: status }, + ); + } } -export function runPluginReleasePretagPackCheck(rootDir = resolve(".")) { +export async function runPluginReleasePretagPackCheck( + rootDir = resolve("."), + options: { timeoutMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? PLUGIN_RELEASE_PRETAG_COMMAND_TIMEOUT_MS; const targets = collectPluginReleasePretagPackTargets(rootDir); const tempRoot = mkdtempSync(join(tmpdir(), "openclaw-plugin-pretag-pack-")); const wrapperDir = join(tempRoot, "bin"); @@ -74,58 +125,87 @@ export function runPluginReleasePretagPackCheck(rootDir = resolve(".")) { ); chmodSync(clawHubWrapper, 0o755); + let unjoinedWork = false; try { - runCommand( - process.execPath, - [ - "--import", - "tsx", - "scripts/check-plugin-npm-runtime-builds.mts", - ...targets.flatMap((target) => ["--package", target.packageDir]), - ], - { - cwd: rootDir, - }, - ); - const packEnv = { ...process.env, CLAWHUB_CLI_PACKAGE: process.env.CLAWHUB_CLI_PACKAGE?.trim() || DEFAULT_CLAWHUB_CLI_PACKAGE, - PATH: `${wrapperDir}:${process.env.PATH ?? ""}`, + PATH: `${wrapperDir}${delimiter}${process.env.PATH ?? ""}`, }; const prebuiltPackEnv = { ...packEnv, OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0", }; for (const [index, target] of targets.entries()) { + console.log(`plugin runtime build: ${target.packageName}`); + await runCommand( + process.execPath, + [ + "--import", + "tsx", + "scripts/check-plugin-npm-runtime-builds.mts", + "--package", + target.packageDir, + ], + { + commandLabel: `node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package ${target.packageDir}`, + cwd: rootDir, + stage: `plugin runtime build for ${target.packageName}`, + timeoutMs, + }, + ); if (target.packNpm) { console.log(`npm pack: ${target.packageName}`); - runCommand("bash", ["scripts/plugin-npm-publish.sh", "--pack-dry-run", target.packageDir], { - cwd: rootDir, - env: prebuiltPackEnv, - quietStdout: true, - }); + await runCommand( + "bash", + ["scripts/plugin-npm-publish.sh", "--pack-dry-run", target.packageDir], + { + commandLabel: `bash scripts/plugin-npm-publish.sh --pack-dry-run ${target.packageDir}`, + cwd: rootDir, + env: prebuiltPackEnv, + quietStdout: true, + stage: `npm pack for ${target.packageName}`, + timeoutMs, + }, + ); } if (target.packClawHub) { const outputDir = join(tempRoot, `clawhub-${index}`); console.log(`ClawHub pack: ${target.packageName}`); - runCommand("bash", ["scripts/plugin-clawhub-publish.sh", "--pack", target.packageDir], { - cwd: rootDir, - env: { - ...prebuiltPackEnv, - OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir, + await runCommand( + "bash", + ["scripts/plugin-clawhub-publish.sh", "--pack", target.packageDir], + { + commandLabel: `bash scripts/plugin-clawhub-publish.sh --pack ${target.packageDir}`, + cwd: rootDir, + env: { + ...prebuiltPackEnv, + OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: outputDir, + }, + quietStdout: true, + stage: `ClawHub pack for ${target.packageName}`, + timeoutMs, }, - quietStdout: true, - }); + ); } } + } catch (error) { + unjoinedWork = hasUnjoinedWork(error); + throw error; } finally { - rmSync(tempRoot, { recursive: true, force: true }); + if (!unjoinedWork) { + rmSync(tempRoot, { recursive: true, force: true }); + } } console.log(`plugin-release-pretag-pack-check: packed ${targets.length} publishable plugins.`); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - runPluginReleasePretagPackCheck(); + try { + await runPluginReleasePretagPackCheck(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = pluginReleasePretagExitCode(error); + } } diff --git a/test/scripts/plugin-release-pretag-pack-check.process-tree.test.ts b/test/scripts/plugin-release-pretag-pack-check.process-tree.test.ts new file mode 100644 index 000000000000..39581da1d798 --- /dev/null +++ b/test/scripts/plugin-release-pretag-pack-check.process-tree.test.ts @@ -0,0 +1,395 @@ +// This proof exercises the pretag caller against a real stalled runtime-build process tree. +import { spawn } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { runPluginReleasePretagPackCheck } from "../../scripts/plugin-release-pretag-pack-check.ts"; +import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; +import { writeJsonFile } from "../helpers/temp-repo.js"; + +const tempDirs = useAutoCleanupTempDirTracker(afterEach); +const posixIt = process.platform === "win32" ? it.skip : it; + +function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 1) { + return false; + } + try { + process.kill(pid, 0); + } catch { + return false; + } + if (process.platform !== "linux") { + return true; + } + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + // kill(pid, 0) also succeeds for a terminated process awaiting reaping. + return stat.charAt(stat.lastIndexOf(")") + 2) !== "Z"; + } catch { + return false; + } +} + +function killProcessIfAlive(pid: number): void { + if (!isProcessAlive(pid)) { + return; + } + try { + process.kill(pid, "SIGKILL"); + } catch { + // The managed runner may have reaped the fixture between the liveness check and signal. + } +} + +function readPid(pidFile: string): number { + return existsSync(pidFile) ? Number(readFileSync(pidFile, "utf8")) : 0; +} + +async function waitFor(condition: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error("timed out waiting for proof fixture"); + } + await delay(25); + } +} + +function createProofRepo(): { + descendantPidFile: string; + directPidFile: string; + repoDir: string; +} { + const repoDir = tempDirs.make("openclaw-plugin-pretag-proof-"); + const scriptsDir = join(repoDir, "scripts"); + const directPidFile = join(repoDir, "runtime-build.pid"); + const descendantPidFile = join(repoDir, "runtime-build-descendant.pid"); + mkdirSync(scriptsDir, { recursive: true }); + writeJsonFile(join(repoDir, "package.json"), { name: "openclaw-test-root", type: "module" }); + writePublishablePluginFixture(repoDir, { + version: "2026.8.26", + publishTo: "npm", + }); + + // The production caller resolves the tsx loader from its cwd before launching this fixture. + const nodeModulesDir = join(repoDir, "node_modules"); + mkdirSync(nodeModulesDir); + symlinkSync(realpathSync(resolve("node_modules/tsx")), join(nodeModulesDir, "tsx"), "dir"); + writeFileSync( + join(scriptsDir, "check-plugin-npm-runtime-builds.mts"), + `import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +const descendant = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdio: "ignore", +}); +writeFileSync(${JSON.stringify(directPidFile)}, String(process.pid)); +writeFileSync(${JSON.stringify(descendantPidFile)}, String(descendant.pid)); +setInterval(() => {}, 1000); +`, + "utf8", + ); + return { descendantPidFile, directPidFile, repoDir }; +} + +describe("scripts/plugin-release-pretag-pack-check.ts process-tree proof", () => { + posixIt( + "bounds a stalled runtime build and leaves no process-tree descendant alive", + async () => { + const { descendantPidFile, directPidFile, repoDir } = createProofRepo(); + const timeoutMs = 2_000; + let descendantPid = 0; + let directPid = 0; + try { + const startedAt = Date.now(); + let thrown: unknown; + try { + await runPluginReleasePretagPackCheck(repoDir, { timeoutMs }); + } catch (error) { + thrown = error; + } + const elapsedMs = Date.now() - startedAt; + + expect(thrown).toMatchObject({ + code: "ETIMEDOUT", + message: + "plugin runtime build for @openclaw/demo-plugin timed out after 2000ms: node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package extensions/demo-plugin", + }); + expect(elapsedMs).toBeGreaterThanOrEqual(1_500); + expect(elapsedMs).toBeLessThan(7_500); + await waitFor(() => existsSync(directPidFile) && existsSync(descendantPidFile)); + directPid = readPid(directPidFile); + descendantPid = readPid(descendantPidFile); + expect(Number.isInteger(directPid) && directPid > 1).toBe(true); + expect(Number.isInteger(descendantPid) && descendantPid > 1).toBe(true); + await waitFor(() => !isProcessAlive(directPid) && !isProcessAlive(descendantPid)); + + const proof = { + timeoutCode: (thrown as { code?: string }).code, + elapsedMs, + completionBounded: elapsedMs < 7_500, + directExited: !isProcessAlive(directPid), + descendantExited: !isProcessAlive(descendantPid), + }; + console.log(`pretag-caller-process-tree-proof ${JSON.stringify(proof)}`); + expect(proof).toMatchObject({ + timeoutCode: "ETIMEDOUT", + completionBounded: true, + directExited: true, + descendantExited: true, + }); + } finally { + directPid ||= readPid(directPidFile); + descendantPid ||= readPid(descendantPidFile); + killProcessIfAlive(directPid); + killProcessIfAlive(descendantPid); + } + }, + 30_000, + ); + + posixIt( + "rejects a build leader that exits successfully while a descendant remains", + async () => { + const { descendantPidFile, directPidFile, repoDir } = createProofRepo(); + writeFileSync(join(repoDir, "scripts/plugin-npm-publish.sh"), "#!/bin/bash\nexit 0\n"); + writeFileSync( + join(repoDir, "scripts/check-plugin-npm-runtime-builds.mts"), + `import { spawn } from "node:child_process"; +import { writeFileSync } from "node:fs"; +const descendant = spawn(process.execPath, ["-e", 'process.send("ready"); setInterval(() => {}, 1000);'], { + stdio: ["ignore", "ignore", "ignore", "ipc"], +}); +descendant.once("message", () => { + writeFileSync(${JSON.stringify(directPidFile)}, String(process.pid)); + writeFileSync(${JSON.stringify(descendantPidFile)}, String(descendant.pid)); + descendant.disconnect(); + descendant.unref(); +}); +`, + ); + try { + await expect( + runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 5_000 }), + ).rejects.toMatchObject({ + code: "EPROCESSGROUP_CLEANUP_FAILED", + processTreeState: "terminated", + }); + expect(readPid(descendantPidFile)).toBeGreaterThan(1); + expect(isProcessAlive(readPid(directPidFile))).toBe(false); + expect(isProcessAlive(readPid(descendantPidFile))).toBe(false); + } finally { + killProcessIfAlive(readPid(directPidFile)); + killProcessIfAlive(readPid(descendantPidFile)); + } + }, + 15_000, + ); +}); + +function startProofCli(repoDir: string) { + const child = spawn( + process.execPath, + [ + "--import", + "tsx", + fileURLToPath(new URL("../../scripts/plugin-release-pretag-pack-check.ts", import.meta.url)), + ], + { + cwd: repoDir, + env: { PATH: process.env.PATH, HOME: repoDir, TMPDIR: repoDir }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + const completion = new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + }>((resolveCompletion, reject) => { + const deadline = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("pretag CLI fixture exceeded its outer safety deadline")); + }, 15_000); + child.once("error", (error) => { + clearTimeout(deadline); + reject(error); + }); + child.once("close", (code, signal) => { + clearTimeout(deadline); + resolveCompletion({ code, signal, stdout, stderr }); + }); + }); + void completion.catch(() => {}); + return { child, completion }; +} + +describe("pretag executable and per-stage deadlines", () => { + for (const [signal, code] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const) { + posixIt( + `joins the build tree and returns ${code} for ${signal}`, + async () => { + const { descendantPidFile, directPidFile, repoDir } = createProofRepo(); + const cli = startProofCli(repoDir); + try { + await waitFor(() => readPid(directPidFile) > 1 && readPid(descendantPidFile) > 1); + expect(isProcessAlive(readPid(directPidFile))).toBe(true); + expect(isProcessAlive(readPid(descendantPidFile))).toBe(true); + expect(cli.child.kill(signal)).toBe(true); + const result = await cli.completion; + expect(result).toMatchObject({ code, signal: null }); + expect(result.stderr).toContain(`failed with exit code ${code}`); + expect(isProcessAlive(readPid(directPidFile))).toBe(false); + expect(isProcessAlive(readPid(descendantPidFile))).toBe(false); + expect(result.stdout).not.toContain("npm pack:"); + } finally { + killProcessIfAlive(readPid(directPidFile)); + killProcessIfAlive(readPid(descendantPidFile)); + if (cli.child.exitCode === null && cli.child.signalCode === null) { + cli.child.kill("SIGKILL"); + } + await cli.completion.catch(() => {}); + } + }, + 20_000, + ); + } + + posixIt( + "preserves a real build failure at the executable boundary and does not pack", + async () => { + const { repoDir } = createProofRepo(); + writeFileSync( + join(repoDir, "scripts/check-plugin-npm-runtime-builds.mts"), + "process.exit(7);\n", + ); + const cli = startProofCli(repoDir); + const result = await cli.completion; + expect(result).toMatchObject({ code: 7, signal: null }); + expect(result.stderr).toContain("failed with exit code 7"); + expect(result.stdout).not.toContain("npm pack:"); + }, + 20_000, + ); + + for (const stage of ["npm", "ClawHub"] as const) { + posixIt( + `bounds the real ${stage} pack command and joins its descendants`, + async () => { + const { repoDir, directPidFile, descendantPidFile } = createProofRepo(); + writePublishablePluginFixture(repoDir, { version: "2026.8.26", publishTo: "both" }); + const scriptsDir = join(repoDir, "scripts"); + writeFileSync( + join(scriptsDir, "stall.mts"), + readFileSync(join(scriptsDir, "check-plugin-npm-runtime-builds.mts")), + ); + writeFileSync( + join(scriptsDir, "check-plugin-npm-runtime-builds.mts"), + "process.exit(0);\n", + ); + writeFileSync( + join(scriptsDir, "plugin-npm-publish.sh"), + stage === "npm" + ? "#!/bin/bash\nexec node --import tsx scripts/stall.mts\n" + : "#!/bin/bash\nexit 0\n", + ); + writeFileSync( + join(scriptsDir, "plugin-clawhub-publish.sh"), + "#!/bin/bash\nexec node --import tsx scripts/stall.mts\n", + ); + try { + await expect( + runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 2_000 }), + ).rejects.toMatchObject({ + code: "ETIMEDOUT", + message: expect.stringContaining(`${stage} pack for @openclaw/demo-plugin timed out`), + }); + expect(readPid(descendantPidFile)).toBeGreaterThan(1); + expect(isProcessAlive(readPid(directPidFile))).toBe(false); + expect(isProcessAlive(readPid(descendantPidFile))).toBe(false); + } finally { + killProcessIfAlive(readPid(directPidFile)); + killProcessIfAlive(readPid(descendantPidFile)); + } + }, + 15_000, + ); + } + + posixIt( + "lets two plugins complete real independent build and pack budgets", + async () => { + const { repoDir } = createProofRepo(); + for (const extensionId of ["demo-plugin", "second-plugin"]) { + writePublishablePluginFixture(repoDir, { + extensionId, + version: "2026.8.26", + publishTo: "both", + }); + } + const scriptsDir = join(repoDir, "scripts"); + const recordPath = join(repoDir, "stages.jsonl"); + const stageCode = `import { appendFileSync } from "node:fs"; +import { setTimeout as delay } from "node:timers/promises"; +appendFileSync(${JSON.stringify(recordPath)}, JSON.stringify({ args: process.argv.slice(2), prebuilt: process.env.OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD, outputDir: process.env.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR }) + "\\n"); +await delay(600); +`; + writeFileSync(join(scriptsDir, "check-plugin-npm-runtime-builds.mts"), stageCode); + writeFileSync(join(scriptsDir, "pack.mts"), stageCode); + for (const name of ["plugin-npm-publish.sh", "plugin-clawhub-publish.sh"]) { + writeFileSync( + join(scriptsDir, name), + '#!/bin/bash\nexec node --import tsx scripts/pack.mts "$@"\n', + ); + } + const started = Date.now(); + await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 2_000 }); + expect(Date.now() - started).toBeGreaterThan(2_000); + const records = readFileSync(recordPath, "utf8") + .trim() + .split("\n") + .map( + (line) => JSON.parse(line) as { args: string[]; prebuilt?: string; outputDir?: string }, + ); + expect(records.map((record) => record.args)).toEqual([ + ["--package", "extensions/demo-plugin"], + ["--pack-dry-run", "extensions/demo-plugin"], + ["--pack", "extensions/demo-plugin"], + ["--package", "extensions/second-plugin"], + ["--pack-dry-run", "extensions/second-plugin"], + ["--pack", "extensions/second-plugin"], + ]); + for (const index of [1, 2, 4, 5]) { + expect(records[index]?.prebuilt).toBe("0"); + } + expect(records[2]?.outputDir).toContain("clawhub-0"); + expect(records[5]?.outputDir).toContain("clawhub-1"); + for (const index of [2, 5]) { + expect(existsSync(records[index]!.outputDir!)).toBe(false); + } + }, + 20_000, + ); +}); diff --git a/test/scripts/plugin-release-pretag-pack-check.test.ts b/test/scripts/plugin-release-pretag-pack-check.test.ts index 6e614486ea29..18a826eeb75f 100644 --- a/test/scripts/plugin-release-pretag-pack-check.test.ts +++ b/test/scripts/plugin-release-pretag-pack-check.test.ts @@ -1,37 +1,35 @@ // Plugin release pretag pack check tests cover its script-local target and command routing. -import { join } from "node:path"; +import { existsSync } from "node:fs"; +import { delimiter, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { collectPluginReleasePretagPackTargets, + pluginReleasePretagExitCode, runPluginReleasePretagPackCheck, } from "../../scripts/plugin-release-pretag-pack-check.ts"; import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js"; import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "../helpers/temp-dir.js"; import { writeJsonFile } from "../helpers/temp-repo.js"; -const { execFileSyncMock } = vi.hoisted(() => ({ - execFileSyncMock: vi.fn(), +const { runManagedCommandMock } = vi.hoisted(() => ({ + runManagedCommandMock: vi.fn(), })); -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); +vi.mock("../../scripts/lib/managed-child-process.mts", async () => { + const actual = await vi.importActual< + typeof import("../../scripts/lib/managed-child-process.mts") + >("../../scripts/lib/managed-child-process.mts"); return { ...actual, - execFileSync: execFileSyncMock, + runManagedCommand: runManagedCommandMock, }; }); const tempDirs: string[] = []; -type ExecOptions = { - cwd?: string; - env?: NodeJS.ProcessEnv; - stdio?: unknown; -}; - afterEach(() => { cleanupTempDirs(tempDirs); - execFileSyncMock.mockReset(); + runManagedCommandMock.mockReset(); }); function createDualPublishPluginRepo() { @@ -44,10 +42,6 @@ function createDualPublishPluginRepo() { return repoDir; } -function callOptions(index: number): ExecOptions { - return execFileSyncMock.mock.calls[index]?.[2] as ExecOptions; -} - describe("scripts/plugin-release-pretag-pack-check.ts", () => { it("collects dual-published plugin targets for npm and ClawHub pack checks", () => { const repoDir = createDualPublishPluginRepo(); @@ -62,44 +56,164 @@ describe("scripts/plugin-release-pretag-pack-check.ts", () => { ]); }); - it("runs runtime build, npm pack, and ClawHub pack commands for selected targets", () => { + it("runs runtime build, npm pack, and ClawHub pack commands as managed process groups", async () => { const repoDir = createDualPublishPluginRepo(); - execFileSyncMock.mockImplementation(() => ""); + runManagedCommandMock.mockResolvedValue(0); - runPluginReleasePretagPackCheck(repoDir); + await runPluginReleasePretagPackCheck(repoDir); - expect(execFileSyncMock).toHaveBeenCalledTimes(3); - expect(execFileSyncMock.mock.calls[0]?.slice(0, 2)).toEqual([ - process.execPath, - [ - "--import", - "tsx", - "scripts/check-plugin-npm-runtime-builds.mts", - "--package", - "extensions/demo-plugin", - ], - ]); - expect(callOptions(0)).toMatchObject({ cwd: repoDir, stdio: "inherit" }); - - expect(execFileSyncMock.mock.calls[1]?.slice(0, 2)).toEqual([ - "bash", - ["scripts/plugin-npm-publish.sh", "--pack-dry-run", "extensions/demo-plugin"], - ]); - expect(callOptions(1)).toMatchObject({ - cwd: repoDir, - env: { OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }, - stdio: ["inherit", "ignore", "inherit"], - }); - - expect(execFileSyncMock.mock.calls[2]?.slice(0, 2)).toEqual([ - "bash", - ["scripts/plugin-clawhub-publish.sh", "--pack", "extensions/demo-plugin"], - ]); - expect(callOptions(2)).toMatchObject({ - cwd: repoDir, - env: { OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }, - stdio: ["inherit", "ignore", "inherit"], - }); - expect(callOptions(2).env?.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR).toContain("clawhub-0"); + expect(runManagedCommandMock).toHaveBeenCalledTimes(3); + expect(runManagedCommandMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + args: [ + "--import", + "tsx", + "scripts/check-plugin-npm-runtime-builds.mts", + "--package", + "extensions/demo-plugin", + ], + bin: process.execPath, + cwd: repoDir, + shell: false, + requireProcessTreeExit: process.platform !== "win32", + stdio: "inherit", + timeoutMs: 600_000, + }), + ); + expect(runManagedCommandMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + args: ["scripts/plugin-npm-publish.sh", "--pack-dry-run", "extensions/demo-plugin"], + bin: "bash", + cwd: repoDir, + env: expect.objectContaining({ OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }), + stdio: ["inherit", "ignore", "inherit"], + timeoutMs: 600_000, + }), + ); + expect(runManagedCommandMock).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + args: ["scripts/plugin-clawhub-publish.sh", "--pack", "extensions/demo-plugin"], + bin: "bash", + cwd: repoDir, + env: expect.objectContaining({ OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0" }), + stdio: ["inherit", "ignore", "inherit"], + timeoutMs: 600_000, + }), + ); + const clawHubOptions = runManagedCommandMock.mock.calls[2]?.[0] as { + env?: NodeJS.ProcessEnv; + }; + expect(clawHubOptions.env?.OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR).toContain("clawhub-0"); }); + + it("gives each selected runtime build its own managed deadline", async () => { + const repoDir = createDualPublishPluginRepo(); + writePublishablePluginFixture(repoDir, { + extensionId: "second-plugin", + version: "2026.4.11", + publishTo: "both", + }); + runManagedCommandMock.mockResolvedValue(0); + + await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 321 }); + + expect(runManagedCommandMock).toHaveBeenCalledTimes(6); + expect(runManagedCommandMock.mock.calls[0]?.[0]).toMatchObject({ + args: expect.arrayContaining(["--package", "extensions/demo-plugin"]), + timeoutMs: 321, + }); + expect(runManagedCommandMock.mock.calls[3]?.[0]).toMatchObject({ + args: expect.arrayContaining(["--package", "extensions/second-plugin"]), + timeoutMs: 321, + }); + }); + + it("applies a caller-provided timeout to every managed command", async () => { + const repoDir = createDualPublishPluginRepo(); + runManagedCommandMock.mockResolvedValue(0); + + await runPluginReleasePretagPackCheck(repoDir, { timeoutMs: 321 }); + + expect(runManagedCommandMock).toHaveBeenCalledTimes(3); + for (const [options] of runManagedCommandMock.mock.calls) { + expect(options).toMatchObject({ timeoutMs: 321 }); + } + }); + + it("preserves nonzero command failure semantics", async () => { + const repoDir = createDualPublishPluginRepo(); + runManagedCommandMock.mockResolvedValueOnce(7); + + let thrown: unknown; + try { + await runPluginReleasePretagPackCheck(repoDir); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ code: 7 }); + expect((thrown as Error).message).toBe( + "plugin runtime build for @openclaw/demo-plugin failed with exit code 7: node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package extensions/demo-plugin", + ); + }); + + it("identifies the stalled release stage without exposing child details", async () => { + const repoDir = createDualPublishPluginRepo(); + runManagedCommandMock.mockResolvedValueOnce(0).mockRejectedValueOnce( + Object.assign(new Error("managed command ETIMEDOUT secret-marker"), { + code: "ETIMEDOUT", + }), + ); + + let thrown: unknown; + try { + await runPluginReleasePretagPackCheck(repoDir); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ code: "ETIMEDOUT" }); + expect((thrown as Error).message).toBe( + "npm pack for @openclaw/demo-plugin timed out after 600000ms: bash scripts/plugin-npm-publish.sh --pack-dry-run extensions/demo-plugin", + ); + expect((thrown as Error).message).not.toContain("secret-marker"); + }); + + it("preserves managed cancellation statuses at the CLI boundary", () => { + expect( + pluginReleasePretagExitCode(Object.assign(new Error("interrupted"), { code: 130 })), + ).toBe(130); + expect(pluginReleasePretagExitCode(Object.assign(new Error("terminated"), { code: 143 }))).toBe( + 143, + ); + expect( + pluginReleasePretagExitCode(Object.assign(new Error("timed out"), { code: "ETIMEDOUT" })), + ).toBe(1); + }); + + it.each(["live", "indeterminate", "terminated"] as const)( + "retains temporary inputs only when managed cleanup reports %s work", + async (processTreeState) => { + const repoDir = createDualPublishPluginRepo(); + runManagedCommandMock.mockResolvedValueOnce(0).mockRejectedValueOnce( + Object.assign(new Error("managed cleanup failed"), { + code: "EPROCESSGROUP_CLEANUP_FAILED", + processTreeState, + }), + ); + await expect(runPluginReleasePretagPackCheck(repoDir)).rejects.toMatchObject({ + code: "EPROCESSGROUP_CLEANUP_FAILED", + processTreeState, + }); + const options = runManagedCommandMock.mock.calls[1]?.[0] as { + env: NodeJS.ProcessEnv; + }; + const wrapperDir = options.env.PATH!.split(delimiter)[0]!; + const tempRoot = dirname(wrapperDir); + tempDirs.push(tempRoot); + expect(existsSync(tempRoot)).toBe(processTreeState !== "terminated"); + }, + ); });