test(release): guard npm package contents
This commit is contained in:
parent
f8073df320
commit
bdef58eeb9
@ -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:
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
138
scripts/check-package-contents.mjs
Normal file
138
scripts/check-package-contents.mjs
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
test/package-contents.test.js
Normal file
60
test/package-contents.test.js
Normal 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",
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user