feat: bootstrap portable Node runtimes
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

This commit is contained in:
2026-08-18 13:53:54 +00:00
parent 2e275b684d
commit dd189c2acb
22 changed files with 900 additions and 96 deletions
+64 -2
View File
@@ -19,8 +19,10 @@ 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);
@@ -75,19 +77,44 @@ cpSync(publicKeyPath, join(out, publicKeyName));
cpSync(resolve("skills/dingtalk-approval/SKILL.md"), join(out, skillName));
rmSync(clientStaging, { recursive: true, force: true });
const artifacts = {
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`],
["{{INSTALLER_URL}}", baseArtifacts.installer.url], ["{{INSTALLER_SHA256}}", baseArtifacts.installer.sha256],
["{{PUBLIC_KEY_URL}}", baseArtifacts.publicKey.url], ["{{PUBLIC_KEY_SHA256}}", baseArtifacts.publicKey.sha256],
["{{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);
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: new Date().toISOString(),
gatewayUrl,
signing: { algorithm: "Ed25519", keyId },
requirements: { node: ">=22.13.0" },
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`);
@@ -103,6 +130,9 @@ const replacements = new Map([
["{{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],
["{{BOOTSTRAP_WINDOWS_URL}}", artifacts.bootstrapWindows.url], ["{{BOOTSTRAP_WINDOWS_SHA256}}", artifacts.bootstrapWindows.sha256],
["{{BOOTSTRAP_POSIX_URL}}", artifacts.bootstrapPosix.url], ["{{BOOTSTRAP_POSIX_SHA256}}", artifacts.bootstrapPosix.sha256],
["{{NODE_VERSION}}", nodeRuntime.version], ["{{NODE_MINIMUM_VERSION}}", nodeRuntime.minimumVersion],
["{{RELEASE_KEY_ID}}", keyId],
]);
let prompt = template;
@@ -133,6 +163,38 @@ function lockedVersion(name) {
return value;
}
function renderTemplate(source, target, replacements) {
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, 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("发布签名私钥不是普通文件");
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
import { readFileSync } from "node:fs";
const catalog = JSON.parse(readFileSync(new URL("../distribution/node-runtime.json", import.meta.url), "utf8"));
const expected = new Map(Object.values(catalog.platforms).map((value) => [value.file, value.sha256]));
const results = await Promise.all(catalog.sourceBases.map(checkSource));
for (const result of results) {
console.log(`${result.ok ? "OK" : "FAIL"} ${result.host}: ${result.message}`);
}
const official = results.find((result) => result.host === "nodejs.org");
const domestic = results.filter((result) => result.host !== "nodejs.org");
if (!official?.ok) throw new Error("Node 官方精确版本源不可用或校验清单不匹配");
if (!domestic.some((result) => result.ok)) throw new Error("没有可用且校验一致的国内 Node 下载源");
async function checkSource(sourceBase) {
const base = new URL(sourceBase);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20_000);
timeout.unref();
try {
const checksumResponse = await fetch(new URL("SHASUMS256.txt", base), { signal: controller.signal });
if (!checksumResponse.ok) return failure(base, `SHASUMS256.txt HTTP ${checksumResponse.status}`);
const checksums = parseChecksums(await checksumResponse.text());
for (const [file, sha256] of expected) {
if (checksums.get(file) !== sha256) return failure(base, `${file} 的官方 SHA-256 不匹配或缺失`);
}
const heads = await Promise.all([...expected.keys()].map(async (file) => {
const response = await fetch(new URL(file, base), { method: "HEAD", signal: controller.signal });
return { file, status: response.status, ok: response.ok };
}));
const unavailable = heads.filter((item) => !item.ok);
if (unavailable.length) return failure(base, `${unavailable[0].file} HTTP ${unavailable[0].status}`);
return { host: base.host, ok: true, message: `${expected.size} 个三平台制品可用且哈希一致` };
} catch (error) {
return failure(base, error instanceof Error ? error.message : String(error));
} finally {
clearTimeout(timeout);
}
}
function parseChecksums(body) {
const values = new Map();
for (const line of body.split(/\r?\n/)) {
const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim());
if (match) values.set(match[2], match[1]);
}
return values;
}
function failure(base, message) { return { host: base.host, ok: false, message }; }
+17 -1
View File
@@ -80,9 +80,25 @@ try {
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)) {
|| !install.includes("onboarding.firstRun=true") || !install.includes("系统无需预装 Node.js")
|| /--host|WorkBuddy|Codex|Claude/.test(install)) {
throw new Error("install.md 版本、占位符或通用安装引导不正确");
}
const windowsBootstrap = readFileSync(join(releaseDir, artifactName(manifest.artifacts.bootstrapWindows, "bootstrapWindows")), "utf8");
const posixBootstrapPath = join(releaseDir, artifactName(manifest.artifacts.bootstrapPosix, "bootstrapPosix"));
const posixBootstrap = readFileSync(posixBootstrapPath, "utf8");
if (windowsBootstrap.includes("{{") || posixBootstrap.includes("{{")
|| !windowsBootstrap.includes("Save-DingDownload") || !windowsBootstrap.includes("Expand-Archive")
|| !posixBootstrap.startsWith("#!/bin/sh\nset -eu\n") || !posixBootstrap.includes("tar -xzf")
|| /latest-v|npm install -g/.test(`${windowsBootstrap}\n${posixBootstrap}`)) {
throw new Error("无 Node 安装引导无效或仍包含不安全的动态版本来源");
}
execFileSync("sh", ["-n", posixBootstrapPath]);
const managedPlatforms = [...(manifest.requirements?.managedNode?.platforms ?? [])].sort();
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.requirements?.managedNode?.version ?? ""))
|| JSON.stringify(managedPlatforms) !== JSON.stringify([
"darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "win32-arm64", "win32-x64",
])) throw new Error("清单没有完整声明三平台私有 Node 运行时");
const skill = readFileSync(join(releaseDir, artifactName(manifest.artifacts.skill, "skill")), "utf8");
if (!skill.startsWith("---\nname: dingtalk-approval\n")) throw new Error("通用 Skill artifact 无效");
} finally {