Build: prune packaged runtime test cargo (#67275)

Merged via squash.

Prepared head SHA: 403f8e5749
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-15 13:18:03 -04:00
committed by GitHub
parent f8705f512b
commit 7c6f2c0a5a
5 changed files with 279 additions and 3 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.
## 2026.4.15-beta.1

View File

@@ -105,9 +105,40 @@ const FORBIDDEN_PRIVATE_QA_CONTENT_MARKERS = [
"qa-lab/runtime-api.js",
] as const;
const FORBIDDEN_PRIVATE_QA_CONTENT_SCAN_PREFIXES = ["dist/"] as const;
const PACKED_TEST_CARGO_DIRECTORY_SEGMENTS = new Set([
"__snapshots__",
"__tests__",
"test",
"tests",
]);
const PACKED_TEST_CARGO_FILE_RE = /(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u;
const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK";
function normalizePackedPath(packedPath: string): string {
return packedPath.replace(/\\/g, "/");
}
function isNodeModulesPackageRoot(segments: string[], index: number): boolean {
const parent = segments[index - 1];
if (parent === "node_modules") {
return true;
}
return parent?.startsWith("@") && segments[index - 2] === "node_modules";
}
function pathContainsPackedTestCargo(packedPath: string): boolean {
const normalizedPath = normalizePackedPath(packedPath);
if (PACKED_TEST_CARGO_FILE_RE.test(normalizedPath)) {
return true;
}
const segments = normalizedPath.split("/").filter(Boolean);
return segments.some(
(segment, index) =>
PACKED_TEST_CARGO_DIRECTORY_SEGMENTS.has(segment) &&
!isNodeModulesPackageRoot(segments, index),
);
}
function normalizeRepoUrl(value: unknown): string {
if (typeof value !== "string") {
return "";
@@ -490,6 +521,7 @@ function collectPackedTarballErrors(): string[] {
...collectControlUiPackErrors(packedPaths),
...collectForbiddenPackedPathErrors(packedPaths),
...collectForbiddenPackedContentErrors(packedPaths),
...collectPackedTestCargoErrors(packedPaths),
];
}
@@ -544,6 +576,17 @@ export function collectForbiddenPackedContentErrors(
return errors.toSorted((left, right) => left.localeCompare(right));
}
export function collectPackedTestCargoErrors(paths: Iterable<string>): string[] {
const errors: string[] = [];
for (const packedPath of paths) {
if (!pathContainsPackedTestCargo(packedPath)) {
continue;
}
errors.push(`npm package must not include test cargo "${packedPath}".`);
}
return errors.toSorted((left, right) => left.localeCompare(right));
}
async function main(): Promise<number> {
const pkg = loadPackageJson();
const now = new Date();

View File

@@ -142,6 +142,15 @@ function readInstalledDependencyVersionFromRoot(depRoot) {
}
const defaultStagedRuntimeDepGlobalPruneSuffixes = [".d.ts", ".map"];
const defaultStagedRuntimeDepGlobalPruneDirectories = [
"__snapshots__",
"__tests__",
"test",
"tests",
];
const defaultStagedRuntimeDepGlobalPruneFilePatterns = [
/(?:^|\/)[^/]+\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/u,
];
const defaultStagedRuntimeDepPruneRules = new Map([
// Type declarations only; runtime resolves through lib/es entrypoints.
["@larksuiteoapi/node-sdk", { paths: ["types"] }],
@@ -182,11 +191,17 @@ const defaultStagedRuntimeDepPruneRules = new Map([
["@jimp/plugin-quantize", { paths: ["src/__image_snapshots__"] }],
["@jimp/plugin-threshold", { paths: ["src/__image_snapshots__"] }],
]);
const runtimeDepsStagingVersion = 5;
const runtimeDepsStagingVersion = 6;
const exactVersionSpecRe = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u;
function resolveRuntimeDepPruneConfig(params = {}) {
return {
globalPruneDirectories:
params.stagedRuntimeDepGlobalPruneDirectories ??
defaultStagedRuntimeDepGlobalPruneDirectories,
globalPruneFilePatterns:
params.stagedRuntimeDepGlobalPruneFilePatterns ??
defaultStagedRuntimeDepGlobalPruneFilePatterns,
globalPruneSuffixes:
params.stagedRuntimeDepGlobalPruneSuffixes ?? defaultStagedRuntimeDepGlobalPruneSuffixes,
pruneRules: params.stagedRuntimeDepPruneRules ?? defaultStagedRuntimeDepPruneRules,
@@ -463,8 +478,8 @@ function walkFiles(rootDir, visitFile) {
return;
}
const queue = [rootDir];
while (queue.length > 0) {
const currentDir = queue.shift();
for (let index = 0; index < queue.length; index += 1) {
const currentDir = queue[index];
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
@@ -489,6 +504,53 @@ function pruneDependencyFilesBySuffixes(depRoot, suffixes) {
});
}
function relativePathSegments(rootDir, fullPath) {
return path.relative(rootDir, fullPath).split(path.sep).filter(Boolean);
}
function isNodeModulesPackageRoot(segments, index) {
const parent = segments[index - 1];
if (parent === "node_modules") {
return true;
}
return parent?.startsWith("@") === true && segments[index - 2] === "node_modules";
}
function pruneDependencyDirectoriesByBasename(depRoot, basenames) {
if (!basenames || basenames.length === 0 || !fs.existsSync(depRoot)) {
return;
}
const basenameSet = new Set(basenames);
const queue = [depRoot];
for (let index = 0; index < queue.length; index += 1) {
const currentDir = queue[index];
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const fullPath = path.join(currentDir, entry.name);
const segments = relativePathSegments(depRoot, fullPath);
if (basenameSet.has(entry.name) && !isNodeModulesPackageRoot(segments, segments.length - 1)) {
removePathIfExists(fullPath);
continue;
}
queue.push(fullPath);
}
}
}
function pruneDependencyFilesByPatterns(depRoot, patterns) {
if (!patterns || patterns.length === 0 || !fs.existsSync(depRoot)) {
return;
}
walkFiles(depRoot, (fullPath) => {
const relativePath = relativePathSegments(depRoot, fullPath).join("/");
if (patterns.some((pattern) => pattern.test(relativePath))) {
removePathIfExists(fullPath);
}
});
}
function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfig) {
const depRoot = dependencyNodeModulesPath(nodeModulesDir, depName);
if (depRoot === null) {
@@ -498,6 +560,8 @@ function pruneStagedInstalledDependencyCargo(nodeModulesDir, depName, pruneConfi
for (const relativePath of pruneRule?.paths ?? []) {
removePathIfExists(path.join(depRoot, relativePath));
}
pruneDependencyDirectoriesByBasename(depRoot, pruneConfig.globalPruneDirectories);
pruneDependencyFilesByPatterns(depRoot, pruneConfig.globalPruneFilePatterns);
pruneDependencyFilesBySuffixes(depRoot, pruneConfig.globalPruneSuffixes);
pruneDependencyFilesBySuffixes(depRoot, pruneRule?.suffixes ?? []);
}
@@ -784,6 +848,10 @@ function createRuntimeDepsFingerprint(packageJson, pruneConfig, params = {}) {
return createHash("sha256")
.update(
JSON.stringify({
globalPruneDirectories: pruneConfig.globalPruneDirectories,
globalPruneFilePatterns: pruneConfig.globalPruneFilePatterns.map((pattern) =>
pattern.toString(),
),
globalPruneSuffixes: pruneConfig.globalPruneSuffixes,
packageJson,
pruneRules: [...pruneConfig.pruneRules.entries()],

View File

@@ -8,6 +8,7 @@ import {
collectControlUiPackErrors,
collectForbiddenPackedContentErrors,
collectForbiddenPackedPathErrors,
collectPackedTestCargoErrors,
collectReleasePackageMetadataErrors,
collectReleaseTagErrors,
parseNpmPackJsonOutput,
@@ -380,6 +381,55 @@ describe("collectForbiddenPackedPathErrors", () => {
});
});
describe("collectPackedTestCargoErrors", () => {
it("rejects packed test files and test directories", () => {
expect(
collectPackedTestCargoErrors([
"dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts",
"dist/extensions/whatsapp/node_modules/pino/test/basic.test.js",
"dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap",
"dist/index.js",
]),
).toEqual([
'npm package must not include test cargo "dist/extensions/webhooks/node_modules/zod/src/v3/tests/all-errors.test.ts".',
'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/@jimp/plugin-crop/src/__snapshots__/crop.test.ts.snap".',
'npm package must not include test cargo "dist/extensions/whatsapp/node_modules/pino/test/basic.test.js".',
]);
});
it("allows normal runtime files", () => {
expect(
collectPackedTestCargoErrors([
"dist/index.js",
"dist/extensions/whatsapp/node_modules/pino/lib/proto.js",
"dist/extensions/webhooks/node_modules/zod/v4/core/api.js",
]),
).toEqual([]);
});
it("allows legitimate package roots named test under node_modules", () => {
expect(
collectPackedTestCargoErrors([
"dist/extensions/fixture-plugin/node_modules/direct/node_modules/test/index.js",
"dist/extensions/fixture-plugin/node_modules/direct/node_modules/@scope/tests/index.js",
]),
).toEqual([]);
});
it("normalizes Windows or mixed separators before classifying test cargo", () => {
expect(
collectPackedTestCargoErrors([
String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`,
String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`,
String.raw`dist\extensions\fixture-plugin\node_modules\direct\node_modules\test\index.js`,
]),
).toEqual([
`npm package must not include test cargo "${String.raw`dist/extensions/fixture-plugin\node_modules/direct/src/runtime.spec.ts`}".`,
`npm package must not include test cargo "${String.raw`dist\extensions\fixture-plugin\node_modules\direct\__tests__\index.js`}".`,
]);
});
});
describe("collectReleaseTagErrors", () => {
it("accepts versions within the two-day CalVer window", () => {
expect(

View File

@@ -362,6 +362,120 @@ describe("stageBundledPluginRuntimeDeps", () => {
expect(fs.existsSync(path.join(pluginDir, ".openclaw-runtime-deps-stamp.json"))).toBe(true);
});
it("prunes staged test cargo from copied runtime dependencies", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
fs.mkdirSync(path.join(directDir, "test"), { recursive: true });
fs.mkdirSync(path.join(directDir, "__snapshots__"), { recursive: true });
fs.mkdirSync(path.join(directDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'runtime';\n", "utf8");
fs.writeFileSync(
path.join(directDir, "test", "index.test.js"),
"module.exports = 'remove';\n",
"utf8",
);
fs.writeFileSync(
path.join(directDir, "__snapshots__", "index.test.ts.snap"),
"snapshot\n",
"utf8",
);
fs.writeFileSync(
path.join(directDir, "src", "runtime.spec.js"),
"module.exports = 'remove';\n",
"utf8",
);
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
expect(
fs.readFileSync(path.join(pluginDir, "node_modules", "direct", "index.js"), "utf8"),
).toBe("module.exports = 'runtime';\n");
expect(
fs.existsSync(path.join(pluginDir, "node_modules", "direct", "test", "index.test.js")),
).toBe(false);
expect(
fs.existsSync(
path.join(pluginDir, "node_modules", "direct", "__snapshots__", "index.test.ts.snap"),
),
).toBe(false);
expect(
fs.existsSync(path.join(pluginDir, "node_modules", "direct", "src", "runtime.spec.js")),
).toBe(false);
});
it("preserves nested runtime dependencies named test or tests", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {
name: "@openclaw/fixture-plugin",
version: "1.0.0",
dependencies: { direct: "1.0.0" },
openclaw: { bundle: { stageRuntimeDependencies: true } },
},
});
const directDir = path.join(repoRoot, "node_modules", "direct");
const nestedTestDir = path.join(directDir, "node_modules", "test");
const scopedTestsDir = path.join(directDir, "node_modules", "@scope", "tests");
fs.mkdirSync(nestedTestDir, { recursive: true });
fs.mkdirSync(scopedTestsDir, { recursive: true });
fs.writeFileSync(
path.join(directDir, "package.json"),
'{ "name": "direct", "version": "1.0.0", "dependencies": { "test": "^1.0.0", "@scope/tests": "^1.0.0" } }\n',
"utf8",
);
fs.writeFileSync(path.join(directDir, "index.js"), "module.exports = 'direct';\n", "utf8");
fs.writeFileSync(
path.join(nestedTestDir, "package.json"),
'{ "name": "test", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(path.join(nestedTestDir, "index.js"), "module.exports = 'test';\n", "utf8");
fs.writeFileSync(
path.join(scopedTestsDir, "package.json"),
'{ "name": "@scope/tests", "version": "1.0.0" }\n',
"utf8",
);
fs.writeFileSync(
path.join(scopedTestsDir, "index.js"),
"module.exports = 'scoped-tests';\n",
"utf8",
);
stageBundledPluginRuntimeDeps({ cwd: repoRoot });
expect(
fs.readFileSync(
path.join(pluginDir, "node_modules", "direct", "node_modules", "test", "index.js"),
"utf8",
),
).toBe("module.exports = 'test';\n");
expect(
fs.readFileSync(
path.join(
pluginDir,
"node_modules",
"direct",
"node_modules",
"@scope",
"tests",
"index.js",
),
"utf8",
),
).toBe("module.exports = 'scoped-tests';\n");
});
it("stages hoisted transitive runtime deps from the root node_modules", () => {
const { pluginDir, repoRoot } = createBundledPluginFixture({
packageJson: {