test(release): guard npm package contents

This commit is contained in:
Vincent Koc 2026-04-27 21:45:20 -07:00
parent f8073df320
commit bdef58eeb9
No known key found for this signature in database
4 changed files with 206 additions and 4 deletions

View File

@ -55,9 +55,11 @@ npm trust github @openclaw/plugin-inspector --repo openclaw/plugin-inspector --f
npm run release:local
```
This runs tests and `npm pack --dry-run`. Once a version has been published,
`npm publish --dry-run` rejects that same version, so the real publish check is
the tag workflow.
This runs tests and the package-contents guard, which shells through
`npm pack --dry-run --json` and fails if exported files, README assets, or
expected examples are missing from the npm tarball. Once a version has been
published, `npm publish --dry-run` rejects that same version, so the real
publish check is the tag workflow.
For normal patch prep before tagging, run the combined local readiness gate:

View File

@ -43,13 +43,15 @@
"files": [
"src",
"examples",
"docs/plugin-inspector-banner.jpg",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"check": "npm test && npm pack --dry-run",
"check": "npm test && npm run release:contents",
"release:crabpot": "node scripts/check-crabpot-followthrough.mjs",
"release:contents": "node scripts/check-package-contents.mjs",
"release:readiness": "npm run release:local && npm run release:crabpot",
"release:local": "npm run check",
"release:notes": "node scripts/release-notes.mjs --unreleased",

View File

@ -0,0 +1,138 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const root = path.resolve(process.argv[2] ?? ".");
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
const readmeText = readFileSync(path.join(root, "README.md"), "utf8");
const filePaths = npmPackFilePaths(root);
const result = buildPackageContentsChecklist({ filePaths, packageJson, readmeText });
printChecklist(result);
if (result.status === "fail") {
process.exitCode = 1;
}
}
export function buildPackageContentsChecklist({ filePaths, packageJson, readmeText = "" }) {
const files = new Set(filePaths);
const checks = [
...requiredFileChecks(files),
...entrypointChecks(files, packageJson),
...readmeAssetChecks(files, readmeText),
...forbiddenPathChecks(filePaths),
];
return {
status: checks.some((check) => check.status === "fail") ? "fail" : "pass",
entryCount: filePaths.length,
checks,
};
}
function requiredFileChecks(files) {
return [
"package.json",
"README.md",
"CHANGELOG.md",
"LICENSE",
"src/cli.js",
"src/index.js",
"examples/github-actions-plugin-inspector.yml",
"examples/plugin-inspector.config.json",
].map((filePath) => presenceCheck(files, "package-required-file", filePath));
}
function entrypointChecks(files, packageJson) {
const checks = [];
for (const [name, filePath] of Object.entries(packageJson.bin ?? {})) {
checks.push(presenceCheck(files, "package-bin-entry", normalizePackagePath(filePath), name));
}
for (const [name, target] of Object.entries(packageJson.exports ?? {})) {
for (const filePath of exportTargets(target)) {
checks.push(presenceCheck(files, "package-export-entry", normalizePackagePath(filePath), name));
}
}
return checks;
}
function readmeAssetChecks(files, readmeText) {
const assetPaths = new Set();
for (const match of readmeText.matchAll(/<img\s+[^>]*src=["']([^"']+)["']/gi)) {
assetPaths.add(match[1]);
}
for (const match of readmeText.matchAll(/!\[[^\]]*]\(([^)]+)\)/g)) {
assetPaths.add(match[1]);
}
return [...assetPaths]
.filter((assetPath) => isPackageRelativeAsset(assetPath))
.map((assetPath) => presenceCheck(files, "package-readme-asset", normalizePackagePath(assetPath)));
}
function forbiddenPathChecks(filePaths) {
const forbiddenPrefixes = ["test/", "scripts/", ".github/"];
return filePaths
.filter((filePath) => forbiddenPrefixes.some((prefix) => filePath.startsWith(prefix)))
.map((filePath) => ({
id: "package-forbidden-path",
status: "fail",
message: `${filePath} should not be published in the npm package`,
}));
}
function presenceCheck(files, id, filePath, detail = filePath) {
return {
id,
status: files.has(filePath) ? "pass" : "fail",
message: `${detail} includes ${filePath}`,
expected: filePath,
actual: files.has(filePath) ? filePath : "missing",
};
}
function exportTargets(target) {
if (typeof target === "string") {
return [target];
}
if (target && typeof target === "object") {
return Object.values(target).flatMap((value) => exportTargets(value));
}
return [];
}
function isPackageRelativeAsset(assetPath) {
return !assetPath.startsWith("http://")
&& !assetPath.startsWith("https://")
&& !assetPath.startsWith("#")
&& !assetPath.startsWith("/");
}
function normalizePackagePath(filePath) {
return filePath.replace(/^\.\//, "");
}
function npmPackFilePaths(root) {
const output = execFileSync("npm", ["pack", "--dry-run", "--json"], {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
});
const pack = JSON.parse(output)[0];
return pack.files.map((file) => file.path).sort();
}
function printChecklist(result) {
console.log(`package contents: ${result.status}`);
console.log(`package entries: ${result.entryCount}`);
for (const check of result.checks) {
console.log(`- ${check.status.toUpperCase()} ${check.id}: ${check.message}`);
if (check.status === "fail" && check.actual !== check.expected) {
console.log(` expected: ${check.expected}`);
console.log(` actual: ${check.actual}`);
}
}
}

View File

@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { buildPackageContentsChecklist } from "../scripts/check-package-contents.mjs";
const packageJson = {
bin: {
"plugin-inspector": "src/cli.js",
},
exports: {
".": "./src/index.js",
"./capture-api": "./src/capture-api.js",
},
};
const requiredFiles = [
"package.json",
"README.md",
"CHANGELOG.md",
"LICENSE",
"src/cli.js",
"src/index.js",
"src/capture-api.js",
"examples/github-actions-plugin-inspector.yml",
"examples/plugin-inspector.config.json",
"docs/plugin-inspector-banner.jpg",
];
test("package contents pass when entrypoints and README assets are packed", () => {
const result = buildPackageContentsChecklist({
filePaths: requiredFiles,
packageJson,
readmeText: '<img src="docs/plugin-inspector-banner.jpg" alt="banner"/>',
});
assert.equal(result.status, "pass");
});
test("package contents fail when README assets are missing", () => {
const result = buildPackageContentsChecklist({
filePaths: requiredFiles.filter((filePath) => filePath !== "docs/plugin-inspector-banner.jpg"),
packageJson,
readmeText: '<img src="docs/plugin-inspector-banner.jpg" alt="banner"/>',
});
assert.equal(result.status, "fail");
assert.equal(result.checks.find((check) => check.id === "package-readme-asset").actual, "missing");
});
test("package contents fail when private release scripts are packed", () => {
const result = buildPackageContentsChecklist({
filePaths: [...requiredFiles, "scripts/release-notes.mjs"],
packageJson,
});
assert.equal(result.status, "fail");
assert.equal(
result.checks.find((check) => check.id === "package-forbidden-path").message,
"scripts/release-notes.mjs should not be published in the npm package",
);
});