From bdef58eeb978537193c77dc1e7f7640520447c38 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 21:45:20 -0700 Subject: [PATCH] test(release): guard npm package contents --- docs/releasing.md | 8 +- package.json | 4 +- scripts/check-package-contents.mjs | 138 +++++++++++++++++++++++++++++ test/package-contents.test.js | 60 +++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 scripts/check-package-contents.mjs create mode 100644 test/package-contents.test.js diff --git a/docs/releasing.md b/docs/releasing.md index 12015d3..cf53b50 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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: diff --git a/package.json b/package.json index 4dc1011..884077e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-package-contents.mjs b/scripts/check-package-contents.mjs new file mode 100644 index 0000000..5108b1b --- /dev/null +++ b/scripts/check-package-contents.mjs @@ -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(/]*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}`); + } + } +} diff --git a/test/package-contents.test.js b/test/package-contents.test.js new file mode 100644 index 0000000..e84667d --- /dev/null +++ b/test/package-contents.test.js @@ -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: '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: '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", + ); +});