fix(plugins): pretag pack checks stop hanging on stalled commands (#111349)

* fix(plugins): use maintained runner for pretag checks

* fix(scripts): preserve pretag cancellation status and prove cleanup

* test(release): exercise pretag pack timeout proof

* test(release): isolate pretag proof source metadata

* test(release): seed prebuilt runtime for pack proof

* test(release): precompute pack manifest metadata

* test(release): drive pack proof through ClawHub CLI

* test(release): use portable hanging pack fixture

* test(release): allow pretag process proof startup

* test(release): prove runtime build process cleanup

* test(release): keep asset proof outside runtime entries

* test(release): complete ClawHub pack proof fixture

* fix(release): budget each plugin runtime build

* test(release): isolate managed process tree proof

* test(plugins): prove pretag caller cleans stalled trees

---------

Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
xingzhou 2026-09-17 05:40:29 +08:00 • committed by GitHub
parent 33e8e3c30f
commit 119520731c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 681 additions and 92 deletions

View file

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

View file

@ -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<void> {
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,
);
});

View file

@ -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<typeof import("node:child_process")>("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");
},
);
});