fix(release): retry plugin dependency install timeouts (#117618)

This commit is contained in:
Vincent Koc
2026-08-02 05:10:11 +08:00
committed by GitHub
parent 5dce1a8237
commit deffca6565
3 changed files with 136 additions and 2 deletions
@@ -9,6 +9,12 @@ export function resolvePluginNpmCommand(
shell: boolean;
windowsVerbatimArguments?: boolean;
};
/** Run package-local npm ci with bounded retries for registry timeouts. */
export function runPluginNpmCiWithRetry(
args: unknown,
options: Record<string, unknown>,
params?: Record<string, unknown>,
): unknown;
/** Build the package.json that should be used while packaging a plugin for npm. */
export function resolveAugmentedPluginNpmPackageJson(params: unknown):
| {
+35 -1
View File
@@ -229,6 +229,36 @@ function spawnCommandSync(command, args, options) {
return spawnSync(command, args, options);
}
/** @internal Directly tested release-script implementation detail. */
export function runPluginNpmCiWithRetry(args, options, params = {}) {
const attempts = params.attempts ?? 3;
const timeoutMs = params.timeoutMs ?? 180_000;
const spawn = params.spawn ?? spawnNpmSync;
const cleanupAttempt = params.cleanupAttempt ?? (() => {});
const pluginDir = params.pluginDir ?? "plugin";
let result;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
result = spawn(args, { ...options, timeout: timeoutMs });
if (result.error?.code !== "ETIMEDOUT") {
return result;
}
// A timed-out npm process can leave a partial tree that makes the next
// package attempt nondeterministic. Restore the staging invariant even
// when the retry budget is exhausted.
cleanupAttempt();
if (attempt === attempts) {
return result;
}
console.error(
`[plugin-npm-publish] bundled dependency install timed out for ${pluginDir} ` +
`(attempt ${attempt}/${attempts}); retrying`,
);
}
return result;
}
function resolveInstalledPackageDir(packageDir, packageName) {
return path.join(packageDir, "node_modules", ...packageName.split("/"));
}
@@ -433,7 +463,7 @@ function installPackageLocalBundledDependencies(params) {
generateNpmPackageLock(params.packageDir, { installStrategy: "shallow" }),
"utf8",
);
const result = spawnNpmSync(
const result = runPluginNpmCiWithRetry(
[
"ci",
"--install-strategy=shallow",
@@ -451,6 +481,10 @@ function installPackageLocalBundledDependencies(params) {
env: process.env,
stdio: ["ignore", "ignore", "inherit"],
},
{
cleanupAttempt: () => fs.rmSync(nodeModulesPath, { recursive: true, force: true }),
pluginDir: params.pluginDir,
},
);
if (result.error) {
throw result.error;
+95 -1
View File
@@ -1,12 +1,13 @@
// Plugin npm manifest tests validate generated plugin package manifests.
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, win32 } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveAugmentedPluginNpmPackageJson,
resolveAugmentedPluginNpmManifest,
resolvePluginNpmCommand,
runPluginNpmCiWithRetry,
withAugmentedPluginNpmManifestForPackage,
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
@@ -188,6 +189,99 @@ describe("plugin npm package manifest staging", () => {
).toThrow("OpenClaw refuses to shell out to bare npm on Windows");
});
it("retries timed-out bundled dependency installs after cleaning partial output", () => {
const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
const spawnResults = [
{ error: timeoutError, status: null },
{ error: undefined, status: 0 },
];
const spawnOptions: Array<Record<string, unknown>> = [];
let cleanupCalls = 0;
const result = runPluginNpmCiWithRetry(
["ci"],
{ cwd: "/tmp/plugin" },
{
cleanupAttempt: () => {
cleanupCalls += 1;
},
pluginDir: "whatsapp",
spawn: (_args: string[], options: Record<string, unknown>) => {
spawnOptions.push(options);
return spawnResults.shift();
},
timeoutMs: 1234,
},
) as { status: number | null };
expect(result.status).toBe(0);
expect(cleanupCalls).toBe(1);
expect(spawnOptions).toEqual([
{ cwd: "/tmp/plugin", timeout: 1234 },
{ cwd: "/tmp/plugin", timeout: 1234 },
]);
});
it("does not retry ordinary bundled dependency install failures", () => {
let spawnCalls = 0;
const result = runPluginNpmCiWithRetry(
["ci"],
{},
{
cleanupAttempt: () => {
throw new Error("cleanup should not run");
},
spawn: () => {
spawnCalls += 1;
return { error: undefined, status: 1 };
},
},
) as { status: number | null };
expect(result.status).toBe(1);
expect(spawnCalls).toBe(1);
});
it("cleans an exhausted timeout before reusing the same package directory", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-timeout-");
const packageDir = join(repoDir, "extensions", "whatsapp");
const nodeModulesPath = join(packageDir, "node_modules");
const timeoutError = Object.assign(new Error("timed out"), { code: "ETIMEDOUT" });
mkdirSync(packageDir, { recursive: true });
const firstResult = runPluginNpmCiWithRetry(
["ci"],
{ cwd: packageDir },
{
attempts: 3,
cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }),
pluginDir: "whatsapp",
spawn: () => {
mkdirSync(nodeModulesPath, { recursive: true });
return { error: timeoutError, status: null };
},
},
) as { error?: NodeJS.ErrnoException };
expect(firstResult.error?.code).toBe("ETIMEDOUT");
expect(existsSync(nodeModulesPath)).toBe(false);
const secondResult = runPluginNpmCiWithRetry(
["ci"],
{ cwd: packageDir },
{
cleanupAttempt: () => rmSync(nodeModulesPath, { recursive: true, force: true }),
pluginDir: "whatsapp",
spawn: () => {
expect(existsSync(nodeModulesPath)).toBe(false);
return { error: undefined, status: 0 };
},
},
) as { status: number | null };
expect(secondResult.status).toBe(0);
});
it("overlays generated channel configs while packing and restores source manifest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
const packageDir = join(repoDir, "extensions", "twitch");