From d53b7854b021400441064ced7ca794ea20f2e158 Mon Sep 17 00:00:00 2001 From: BlackChar92 Date: Thu, 16 Jul 2026 11:54:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20gen:integrity=20=E2=80=94=20si?= =?UTF-8?q?gned=20release-integrity=20manifest=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates integrity.json pinning the npm tarball (sha512/shasum via reproducible npm pack) and the agent-facing raw files (mcp-api-list.md, README, CHANGELOG, LICENSE, package.json — sha256) to a git commit anchor, with the tool-surface counts cross-parsed from the generated catalog. Deterministic on an unchanged tree (no wall clock — commit date only), so a CI freshness check can enforce it like gen:api-list. The manifest itself is gitignored: it's a release artifact (generate per release, GPG detach-sign, attach to the GitHub Release), not a committed file — committing it would chase its own commit hash. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 ++- package.json | 1 + scripts/gen-integrity.ts | 82 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 scripts/gen-integrity.ts diff --git a/.gitignore b/.gitignore index 115f89f..1572b64 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,7 @@ CLAUDE.md *.docx /test-results/ /blob-report/ -/tests/**-snapshots/**-darwin.png \ No newline at end of file +/tests/**-snapshots/**-darwin.png +# Release integrity manifest — generated per release (npm run gen:integrity), attach to the GitHub Release instead of committing +integrity.json +integrity.json.asc diff --git a/package.json b/package.json index e790c5f..a4dc110 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev:http": "tsx watch src/server/http-server.ts", "test": "vitest run", "gen:api-list": "tsx scripts/gen-mcp-api-list.ts > mcp-api-list.md", + "gen:integrity": "npm run build --silent && tsx scripts/gen-integrity.ts > integrity.json", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/scripts/gen-integrity.ts b/scripts/gen-integrity.ts new file mode 100644 index 0000000..413c365 --- /dev/null +++ b/scripts/gen-integrity.ts @@ -0,0 +1,82 @@ +/** + * Generate `integrity.json` — a release-integrity manifest for this MCP server. + * + * Why: agents and operators consume this server through two channels — the npm + * tarball and raw files fetched from GitHub (most importantly `mcp-api-list.md`, + * the offline tool catalog). This manifest pins both to content hashes anchored + * at a specific git commit, so a consumer can verify that what they downloaded + * is what this repository state actually produced (and that the tool surface + * wasn't tampered with in transit). Pair it with a detached GPG signature + * (`integrity.json.asc`) to also prove origin: + * + * npm run gen:integrity + * gpg --armor --detach-sign integrity.json + * gpg --verify integrity.json.asc integrity.json + * + * Output is deterministic for a given commit: npm tarballs have normalized + * mtimes, tsc output is stable, and the manifest records the commit's author + * date instead of the wall clock — regenerating on an unchanged tree yields a + * byte-identical file (same property `gen:api-list` has, and CI can enforce it + * the same way). + */ +import { createHash } from "crypto"; +import { execSync } from "child_process"; +import { readFileSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const sh = (cmd: string) => execSync(cmd, { cwd: ROOT, encoding: "utf8" }).trim(); +const sha256 = (path: string) => + createHash("sha256").update(readFileSync(join(ROOT, path))).digest("hex"); + +// --- package + git anchors ------------------------------------------------- +const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); +const gitCommit = sh("git rev-parse HEAD"); +const gitCommitDate = sh("git show -s --format=%cI HEAD"); +const gitTreeDirty = sh("git status --porcelain") !== ""; + +// --- npm tarball integrity (what `npm publish` would ship) ------------------ +// `npm pack --dry-run --json` computes the reproducible tarball without +// leaving a .tgz behind. Requires a fresh `npm run build` (build/ is packed). +if (!existsSync(join(ROOT, "build"))) { + console.error("error: build/ missing — run `npm run build` first (the tarball packs it)."); + process.exit(1); +} +const pack = JSON.parse(sh("npm pack --dry-run --json 2>/dev/null"))[0]; + +// --- agent-facing raw files (also fetched directly from GitHub) ------------- +const RAW_FILES = ["mcp-api-list.md", "README.md", "CHANGELOG.md", "LICENSE", "package.json"]; + +// --- tool-surface cross-check from the generated catalog -------------------- +const catalog = readFileSync(join(ROOT, "mcp-api-list.md"), "utf8"); +const toolCount = Number(/\*\*Total tools\*\*:\s*(\d+)/.exec(catalog)?.[1] ?? NaN); +const readOnly = Number(/\*\*Read-only tools\*\*:\s*(\d+)/.exec(catalog)?.[1] ?? NaN); +const writeTools = Number(/\*\*Write tools\*\*:\s*(\d+)/.exec(catalog)?.[1] ?? NaN); +const destructive = Number(/destructive:\s*(\d+)/.exec(catalog)?.[1] ?? NaN); +if (!Number.isFinite(toolCount)) { + console.error("error: could not parse tool count from mcp-api-list.md — regenerate it first."); + process.exit(1); +} + +const manifest = { + name: pkg.name, + version: pkg.version, + git: { commit: gitCommit, commitDate: gitCommitDate, treeDirty: gitTreeDirty }, + toolSurface: { total: toolCount, readOnly, write: writeTools, destructive }, + npmTarball: { + filename: pack.filename, + integrity: pack.integrity, // sha512, same value npm records in package-lock + shasum: pack.shasum, + files: pack.entryCount, + unpackedSize: pack.unpackedSize, + }, + files: Object.fromEntries(RAW_FILES.map((f) => [f, { sha256: sha256(f) }])), + verify: { + tarball: "npm pack --dry-run --json # compare .integrity / .shasum", + file: "shasum -a 256 # compare files[].sha256", + signature: "gpg --verify integrity.json.asc integrity.json", + }, +}; + +console.log(JSON.stringify(manifest, null, 2));