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
254 lines
14 KiB
JavaScript
254 lines
14 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" },
|
|
"download-mirror-base-url": { type: "string", multiple: true, default: [] },
|
|
"created-at": { 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 downloadMirrorBaseUrls = [...new Set(values["download-mirror-base-url"].map((value) =>
|
|
httpsBase(value, "--download-mirror-base-url")))].filter((value) => value !== downloadBaseUrl);
|
|
if (downloadMirrorBaseUrls.length > 4) throw new Error("--download-mirror-base-url 最多允许 4 个");
|
|
const createdAt = releaseCreatedAt(values["created-at"]);
|
|
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 nodeRuntime = JSON.parse(readFileSync(resolve("distribution/node-runtime.json"), "utf8"));
|
|
const version = String(packageMetadata.version ?? "").trim();
|
|
if (!version) throw new Error("package.json 缺少版本号");
|
|
validateNodeRuntime(nodeRuntime);
|
|
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", "version.js"]) {
|
|
cpSync(resolve("dist", "src", name), join(clientStaging, "dist", "src", name));
|
|
}
|
|
cpSync(resolve("skills/dingtalk-approval"), join(clientStaging, "skill", "dingtalk-approval"), { recursive: true });
|
|
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", "skill/dingtalk-approval"],
|
|
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 baseArtifacts = {
|
|
installer: artifact(installerName),
|
|
publicKey: { ...artifact(publicKeyName), keyId },
|
|
client: artifact(clientName),
|
|
skill: artifact(skillName),
|
|
};
|
|
const commonBootstrapReplacements = new Map([
|
|
["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion],
|
|
["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
|
|
["{{MANIFEST_MIRROR_URLS_POWERSHELL}}", downloadMirrorBaseUrls.map((value) => JSON.stringify(`${value}/manifest.json`)).join(", ")],
|
|
["{{MANIFEST_MIRROR_URLS_POSIX}}", downloadMirrorBaseUrls.map((value) => `${value}/manifest.json`).join(" ")],
|
|
["{{INSTALLER_URL}}", baseArtifacts.installer.url], ["{{INSTALLER_SHA256}}", baseArtifacts.installer.sha256],
|
|
["{{INSTALLER_URLS_POWERSHELL}}", artifactUrls(baseArtifacts.installer).map((value) => JSON.stringify(value)).join(", ")],
|
|
["{{INSTALLER_URLS_POSIX}}", artifactUrls(baseArtifacts.installer).join(" ")],
|
|
["{{PUBLIC_KEY_URL}}", baseArtifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", baseArtifacts.publicKey.sha256],
|
|
["{{PUBLIC_KEY_URLS_POWERSHELL}}", artifactUrls(baseArtifacts.publicKey).map((value) => JSON.stringify(value)).join(", ")],
|
|
["{{PUBLIC_KEY_URLS_POSIX}}", artifactUrls(baseArtifacts.publicKey).join(" ")],
|
|
["{{NODE_SOURCE_BASES_POWERSHELL}}", nodeRuntime.sourceBases.map((value) => JSON.stringify(value)).join(", ")],
|
|
["{{NODE_SOURCE_BASES_POSIX}}", nodeRuntime.sourceBases.join(" ")],
|
|
...Object.entries(nodeRuntime.platforms).flatMap(([platform, runtime]) => {
|
|
const prefix = `{{NODE_${platform.replace(/-/g, "_").toUpperCase()}_`;
|
|
return [[`${prefix}FILE}}`, runtime.file], [`${prefix}SHA256}}`, runtime.sha256]];
|
|
}),
|
|
]);
|
|
const bootstrapWindowsName = "dingtalk-approval-bootstrap.ps1";
|
|
const bootstrapPosixName = "dingtalk-approval-bootstrap.sh";
|
|
renderTemplate("distribution/bootstrap-windows.template.ps1", join(out, bootstrapWindowsName), commonBootstrapReplacements, { utf8Bom: true });
|
|
renderTemplate("distribution/bootstrap-posix.template.sh", join(out, bootstrapPosixName), commonBootstrapReplacements);
|
|
execFileSync("sh", ["-n", join(out, bootstrapPosixName)]);
|
|
const artifacts = {
|
|
...baseArtifacts,
|
|
bootstrapWindows: artifact(bootstrapWindowsName),
|
|
bootstrapPosix: artifact(bootstrapPosixName),
|
|
};
|
|
const manifest = {
|
|
schemaVersion: 3,
|
|
version,
|
|
createdAt,
|
|
gatewayUrl,
|
|
signing: { algorithm: "Ed25519", keyId },
|
|
...(downloadMirrorBaseUrls.length ? {
|
|
distribution: { manifestMirrors: downloadMirrorBaseUrls.map((value) => `${value}/manifest.json`) },
|
|
} : {}),
|
|
requirements: {
|
|
node: `>=${nodeRuntime.minimumVersion}`,
|
|
managedNode: { version: nodeRuntime.version, platforms: Object.keys(nodeRuntime.platforms).sort() },
|
|
},
|
|
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 replacements = new Map([
|
|
["{{VERSION}}", version], ["{{MANIFEST_URL}}", `${downloadBaseUrl}/manifest.json`],
|
|
["{{MANIFEST_SIGNATURE_URL}}", `${downloadBaseUrl}/manifest.sig`], ["{{GATEWAY_URL}}", gatewayUrl],
|
|
["{{DOWNLOAD_MIRROR_BASE_URLS_MARKDOWN}}", downloadMirrorBaseUrls.length
|
|
? downloadMirrorBaseUrls.map((value) => ` - \`${value}\``).join("\n") : " - 未配置"],
|
|
["{{INSTALLER_URL}}", artifacts.installer.url], ["{{INSTALLER_SHA256}}", artifacts.installer.sha256],
|
|
["{{PUBLIC_KEY_URL}}", artifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", artifacts.publicKey.sha256],
|
|
["{{BOOTSTRAP_WINDOWS_URL}}", artifacts.bootstrapWindows.url], ["{{BOOTSTRAP_WINDOWS_SHA256}}", artifacts.bootstrapWindows.sha256],
|
|
["{{BOOTSTRAP_WINDOWS_URLS_POWERSHELL}}", artifactUrls(artifacts.bootstrapWindows).map((value) => JSON.stringify(value)).join(", ")],
|
|
["{{BOOTSTRAP_WINDOWS_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapWindows.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"],
|
|
["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256],
|
|
["{{BOOTSTRAP_POSIX_URLS_POSIX}}", artifactUrls(artifacts.bootstrapPosix).join(" ")],
|
|
["{{BOOTSTRAP_POSIX_MIRROR_URLS_MARKDOWN}}", (artifacts.bootstrapPosix.mirrors ?? []).map((value) => ` - \`${value}\``).join("\n") || " - 未配置"],
|
|
["{{COMPAT_INSTALL_URL}}", `${downloadBaseUrl}/compat-install.md`],
|
|
["{{COMPAT_INSTALL_MIRROR_URLS_MARKDOWN}}", downloadMirrorBaseUrls.length
|
|
? downloadMirrorBaseUrls.map((value) => ` - \`${value}/compat-install.md\``).join("\n") : " - 未配置"],
|
|
["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion],
|
|
["{{RELEASE_KEY_ID}}", keyId],
|
|
]);
|
|
renderTemplate("distribution/agent-install.template.md", join(out, "install.md"), replacements);
|
|
renderTemplate("distribution/agent-compat-install.template.md", join(out, "compat-install.md"), replacements);
|
|
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)}`,
|
|
...(downloadMirrorBaseUrls.length ? { mirrors: downloadMirrorBaseUrls.map((value) => `${value}/${basename(name)}`) } : {}),
|
|
sha256: sha256(join(out, name)),
|
|
};
|
|
}
|
|
|
|
function artifactUrls(value) { return [value.url, ...(value.mirrors ?? [])]; }
|
|
|
|
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 renderTemplate(source, target, replacements, options = {}) {
|
|
let body = readFileSync(resolve(source), "utf8");
|
|
for (const [from, to] of replacements) body = body.replaceAll(from, String(to));
|
|
if (/\{\{[^}]+\}\}/.test(body)) throw new Error(`${source} 仍包含未替换占位符`);
|
|
writeFileSync(target, options.utf8Bom ? `\uFEFF${body}` : body, "utf8");
|
|
}
|
|
|
|
function validateNodeRuntime(value) {
|
|
if (value?.schemaVersion !== 1 || !/^\d+\.\d+\.\d+$/.test(value.version)
|
|
|| !/^\d+\.\d+\.\d+$/.test(value.minimumVersion)) {
|
|
throw new Error("node-runtime.json 版本信息无效");
|
|
}
|
|
if (!Array.isArray(value.sourceBases) || value.sourceBases.length < 2) throw new Error("Node 运行时至少需要两个下载源");
|
|
for (const source of value.sourceBases) {
|
|
const url = parseHttps(source, "Node source base");
|
|
if (!url.pathname.endsWith(`/v${value.version}/`) || url.search || url.hash) {
|
|
throw new Error("Node 下载源必须固定精确版本目录");
|
|
}
|
|
}
|
|
const expected = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64"];
|
|
if (JSON.stringify(Object.keys(value.platforms ?? {}).sort()) !== JSON.stringify(expected)) {
|
|
throw new Error("Node 运行时必须完整覆盖 Windows/Linux/macOS 的 x64 与 ARM64");
|
|
}
|
|
for (const [platform, runtime] of Object.entries(value.platforms)) {
|
|
if (typeof runtime.file !== "string" || !runtime.file.includes(`v${value.version}`)
|
|
|| !/^[a-f0-9]{64}$/.test(runtime.sha256)) throw new Error(`Node 运行时 ${platform} 无效`);
|
|
if (platform.startsWith("win32-") ? !runtime.file.endsWith(".zip") : !runtime.file.endsWith(".tar.gz")) {
|
|
throw new Error(`Node 运行时 ${platform} 使用了不支持的归档格式`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function releaseCreatedAt(value) {
|
|
if (value === undefined) return new Date().toISOString();
|
|
const timestamp = Date.parse(String(value));
|
|
if (!Number.isFinite(timestamp)) throw new Error("--created-at 必须是有效 ISO-8601 时间");
|
|
return new Date(timestamp).toISOString();
|
|
}
|