Files
dingMCP/scripts/build-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

157 lines
7.5 KiB
JavaScript

#!/usr/bin/env node
import { createHash, createPublicKey, sign as signData } from "node:crypto";
import { cpSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { basename, join, resolve, sep } from "node:path";
import { execFileSync } from "node:child_process";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: {
"gateway-url": { type: "string" },
"download-base-url": { type: "string" },
"signing-key": { type: "string", default: ".secrets/release-signing-private.pem" },
out: { type: "string", default: "release" },
} });
const gatewayUrl = exactHttps(values["gateway-url"], "/mcp", "--gateway-url");
const downloadBaseUrl = httpsBase(values["download-base-url"], "--download-base-url");
const out = resolve(String(values.out));
const projectRoot = resolve(".");
const signingKeyPath = resolve(String(values["signing-key"]));
const publicKeyPath = resolve("distribution/release-signing-public.pem");
const packageMetadata = JSON.parse(readFileSync(resolve("package.json"), "utf8"));
const packageLock = JSON.parse(readFileSync(resolve("package-lock.json"), "utf8"));
const version = String(packageMetadata.version ?? "").trim();
if (!version) throw new Error("package.json 缺少版本号");
if (out === projectRoot || !out.startsWith(`${projectRoot}${sep}`)) throw new Error("--out 必须是项目目录下的专用子目录");
assertPrivateKey(signingKeyPath);
const privateKey = readFileSync(signingKeyPath);
const publicKey = readFileSync(publicKeyPath);
const derivedPublic = createPublicKey(privateKey).export({ type: "spki", format: "pem" });
if (normalizePem(derivedPublic) !== normalizePem(publicKey)) throw new Error("发布私钥与仓库固定公钥不匹配");
const keyId = releaseKeyId(publicKey);
const clientStaging = join(out, ".client-staging");
rmSync(out, { recursive: true, force: true });
mkdirSync(out, { recursive: true, mode: 0o755 });
execFileSync("npm", ["run", "build"], { stdio: "inherit", env: npmEnvironment() });
mkdirSync(join(clientStaging, "dist", "src"), { recursive: true });
for (const name of ["bridge.js", "entrypoint.js", "client-installer.js", "approval-contract.js"]) {
cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name));
}
const clientPackage = {
name: "dingtalk-approval-client",
version,
description: "Lightweight user client for the protected DingTalk OA and XFT approval gateway.",
type: "module",
bin: {
"dingtalk-approval": "dist/src/client-installer.js",
"dingtalk-approval-bridge": "dist/src/bridge.js",
},
files: ["dist/src"],
engines: { node: ">=22.13" },
dependencies: {
"@modelcontextprotocol/sdk": lockedVersion("@modelcontextprotocol/sdk"),
zod: lockedVersion("zod"),
},
bundledDependencies: ["@modelcontextprotocol/sdk", "zod"],
};
writeFileSync(join(clientStaging, "package.json"), `${JSON.stringify(clientPackage, null, 2)}\n`, "utf8");
execFileSync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], {
cwd: clientStaging, stdio: "inherit", env: npmEnvironment(),
});
const packed = JSON.parse(execFileSync("npm", ["pack", "--json", "--pack-destination", out], {
cwd: clientStaging, encoding: "utf8", env: npmEnvironment(),
}));
const clientName = packed?.[0]?.filename;
if (typeof clientName !== "string") throw new Error("npm pack 没有返回客户端包文件名");
const installerName = "dingtalk-approval-installer.mjs";
const publicKeyName = "release-signing-public.pem";
const skillName = "dingtalk-approval-SKILL.md";
cpSync(resolve("dist/src/client-installer.js"), join(out, installerName));
cpSync(publicKeyPath, join(out, publicKeyName));
cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName));
rmSync(clientStaging, { recursive: true, force: true });
const artifacts = {
installer: artifact(installerName),
publicKey: { ...artifact(publicKeyName), keyId },
client: artifact(clientName),
skill: artifact(skillName),
};
const manifest = {
schemaVersion: 3,
version,
createdAt: new Date().toISOString(),
gatewayUrl,
signing: { algorithm: "Ed25519", keyId },
requirements: { node: ">=22.13.0" },
artifacts,
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(out, "manifest.json"), manifestBytes);
const signature = signData(null, manifestBytes, privateKey).toString("base64");
writeFileSync(join(out, "manifest.sig"), `${JSON.stringify({
schemaVersion: 1, algorithm: "Ed25519", keyId, signature,
}, null, 2)}\n`, "utf8");
const template = readFileSync(resolve("distribution/agent-install.template.md"), "utf8");
const replacements = new Map([
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256],
["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256],
["{{RELEASE_KEY_ID}}", keyId],
]);
let prompt = template;
for (const [from, to] of replacements) prompt = prompt.replaceAll(from, to);
if (/\{\{[^}]+\}\}/.test(prompt)) throw new Error("安装指令仍包含未替换占位符");
writeFileSync(join(out, "install.md"), prompt, "utf8");
const checksums = Object.values(artifacts).map((value) => `${value.sha256} ${basename(new URL(value.url).pathname)}`);
checksums.push(`${sha256(join(out, "manifest.json"))} manifest.json`, `${sha256(join(out, "manifest.sig"))} manifest.sig`);
writeFileSync(join(out, "SHA256SUMS"), `${checksums.join("\n")}\n`, "utf8");
console.log(`发布目录:${out}`);
console.log(`签名密钥:${keyId}`);
console.log(`给用户的唯一链接:${downloadBaseUrl}/install.md`);
function artifact(name) {
return { url: `${downloadBaseUrl}/${basename(name)}`, sha256: sha256(join(out, name)) };
}
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 normalizePem(value) { return Buffer.from(value).toString("utf8").replace(/\r/g, "").trim(); }
function npmEnvironment() { return { ...process.env, npm_config_cache: join(projectRoot, ".npm-cache") }; }
function lockedVersion(name) {
const value = packageLock.packages?.[`node_modules/${name}`]?.version;
if (typeof value !== "string" || !/^\d+\.\d+\.\d+/.test(value)) throw new Error(`package-lock.json 缺少 ${name} 的固定版本`);
return value;
}
function assertPrivateKey(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new Error("发布签名私钥不是普通文件");
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) throw new Error("发布签名私钥权限必须为 600");
}
function exactHttps(value, pathname, flag) {
const url = parseHttps(value, flag);
if (url.pathname !== pathname || url.search || url.hash) throw new Error(`${flag} 必须是 HTTPS ${pathname} 地址`);
return url.toString().replace(/\/$/, "");
}
function httpsBase(value, flag) {
const url = parseHttps(value, flag);
if (url.search || url.hash) throw new Error(`${flag} 不能包含 query 或 hash`);
return url.toString().replace(/\/$/, "");
}
function parseHttps(value, flag) {
if (!value) throw new Error(`缺少 ${flag}`);
const url = new URL(String(value));
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`${flag} 必须使用无内嵌凭证的 HTTPS URL`);
return url;
}