Files
dingMCP/scripts/verify-release.mjs
T
gitea-admin 504145aec6
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s
feat: initial public release
2026-08-17 03:04:09 +00:00

119 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { createHash, createPublicKey, verify as verifySignature } from "node:crypto";
import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: { "release-dir": { type: "string", default: "release" } } });
const releaseDir = resolve(String(values["release-dir"]));
const manifestBytes = readFileSync(join(releaseDir, "manifest.json"));
const manifest = JSON.parse(manifestBytes.toString("utf8"));
const signature = JSON.parse(readFileSync(join(releaseDir, "manifest.sig"), "utf8"));
const publicKey = readFileSync(resolve("distribution/release-signing-public.pem"));
const keyId = releaseKeyId(publicKey);
const version = String(manifest.version ?? "");
if (manifest.schemaVersion !== 3 || !/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error("manifest 缺少有效 schemaVersion 3 或版本号");
if (manifest.signing?.algorithm !== "Ed25519" || manifest.signing?.keyId !== keyId) throw new Error("manifest 发布公钥编号不匹配");
if (signature.schemaVersion !== 1 || signature.algorithm !== "Ed25519" || signature.keyId !== keyId) throw new Error("manifest.sig 元数据不匹配");
if (!verifySignature(null, manifestBytes, publicKey, Buffer.from(String(signature.signature), "base64"))) throw new Error("manifest Ed25519 签名无效");
for (const [kind, artifact] of Object.entries(manifest.artifacts ?? {})) {
const name = artifactName(artifact, kind);
const expected = String(artifact.sha256 ?? "");
const actual = sha256(join(releaseDir, name));
if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) throw new Error(`${kind} SHA-256 不匹配`);
}
if (manifest.artifacts.publicKey.keyId !== keyId) throw new Error("publicKey artifact keyId 不匹配");
const root = mkdtempSync(join(tmpdir(), "dingmcp-release-verify-"));
try {
const clientName = artifactName(manifest.artifacts.client, "client");
const archivePath = join(releaseDir, clientName);
const members = execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
const verboseMembers = execFileSync("tar", ["-tvzf", archivePath], { encoding: "utf8" }).split(/\r?\n/).filter(Boolean);
if (verboseMembers.some((line) => line.startsWith("l") || line.startsWith("h"))) {
throw new Error(`${clientName} 含符号链接或硬链接,拒绝解压`);
}
for (const member of members) {
if (member.startsWith("/") || member.split("/").includes("..")) throw new Error(`${clientName} 含不安全归档路径:${member}`);
if (/(?:^|\/)(?:\.secrets|\.runtime|auth\.sqlite|session\.json)(?:\/|$)/.test(member)) throw new Error(`${clientName} 含禁止发布文件:${member}`);
}
const clientRoot = join(root, "client");
execFileSync("mkdir", ["-p", clientRoot]);
execFileSync("tar", ["-xzf", archivePath, "-C", clientRoot]);
scanTree(clientRoot);
const packageMetadata = JSON.parse(readFileSync(join(root, "client", "package", "package.json"), "utf8"));
if (packageMetadata.name !== "dingtalk-approval-client" || packageMetadata.version !== version) throw new Error("客户端 package.json 名称或版本不一致");
if (JSON.stringify(Object.keys(packageMetadata.bin ?? {}).sort()) !== JSON.stringify(["dingtalk-approval", "dingtalk-approval-bridge"])) {
throw new Error("客户端只能发布安装 CLI 和 Bridge 两个 bin");
}
if (JSON.stringify([...(packageMetadata.bundledDependencies ?? packageMetadata.bundleDependencies ?? [])].sort()) !== JSON.stringify(["@modelcontextprotocol/sdk", "zod"])) {
throw new Error("客户端必须内置全部签名运行依赖");
}
for (const dependency of ["@modelcontextprotocol/sdk", "zod"]) {
if (!existsFile(join(root, "client", "package", "node_modules", ...dependency.split("/"), "package.json"))) {
throw new Error(`客户端缺少内置依赖:${dependency}`);
}
}
for (const target of Object.values(packageMetadata.bin ?? {})) {
const body = readFileSync(join(root, "client", "package", String(target)), "utf8");
if (!body.startsWith("#!/usr/bin/env node\n")) throw new Error(`客户端 CLI 缺少 Node shebang:${target}`);
}
const offlineRoot = join(root, "offline-install");
execFileSync("npm", [
"install", "--prefix", offlineRoot, "--cache", join(root, "empty-cache"), "--offline",
"--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", join(releaseDir, artifactName(manifest.artifacts.client, "client")),
], { stdio: "pipe", env: { ...process.env, npm_config_update_notifier: "false" } });
if (!existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "bridge.js"))
|| !existsFile(join(offlineRoot, "node_modules", "dingtalk-approval-client", "dist", "src", "approval-contract.js"))) {
throw new Error("签名客户端无法在空缓存中离线安装");
}
const installer = readFileSync(join(releaseDir, artifactName(manifest.artifacts.installer, "installer")), "utf8");
if (!installer.startsWith("#!/usr/bin/env node\n") || /from ["']\.\//.test(installer)) throw new Error("首次安装器必须是无相对依赖的独立 Node 脚本");
const install = readFileSync(join(releaseDir, "install.md"), "utf8");
if (install.includes("{{") || !install.includes(`版本:\`${version}\``)
|| !install.includes("唯一通用安装入口") || !install.includes("postInstall")
|| !install.includes("onboarding.firstRun=true") || /--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 版本、占位符或通用安装引导不正确");
}
const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8");
if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效");
} finally {
rmSync(root, { recursive: true, force: true });
}
console.log(`发行包签名与客户端校验通过:${version}`);
function artifactName(value, kind) {
const url = value?.url;
if (typeof url !== "string") throw new Error(`manifest 缺少 ${kind} URL`);
return basename(new URL(url).pathname);
}
function sha256(path) { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
function releaseKeyId(value) {
const der = createPublicKey(value).export({ type: "spki", format: "der" });
return createHash("sha256").update(der).digest("hex");
}
function existsFile(path) { try { return statSync(path).isFile(); } catch { return false; } }
function scanTree(rootPath) {
for (const path of walk(rootPath)) {
if (statSync(path).size > 2_000_000) continue;
const body = readFileSync(path, "utf8");
if (/(?:^|\r?\n)-----BEGIN (?:RSA |EC |ENCRYPTED )?PRIVATE KEY-----\r?\n(?:[A-Za-z0-9+/=]{20,}\r?\n)+/.test(body)) {
throw new Error(`发行物疑似包含私钥:${path}`);
}
if (!path.includes(`${sep}node_modules${sep}`)
&& (/\/(?:home|Users)\/[a-zA-Z0-9._-]+\//.test(body) || /[A-Za-z]:\\Users\\[a-zA-Z0-9._-]+\\/.test(body))) {
throw new Error(`发行物包含机器私有绝对路径:${path}`);
}
}
}
function* walk(rootPath) {
for (const entry of readdirSync(rootPath, { withFileTypes: true })) {
const path = join(rootPath, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (entry.isFile()) yield path;
}
}