diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a7b8e4b7..2012c4301 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,6 +59,21 @@ jobs: - name: Ensure zip and tar (per-platform binary archives) run: sudo apt-get update && sudo apt-get install -y zip + # bun build --compile leaves an invalid darwin linker signature. rcodesign + # re-signs those Mach-O files on the Linux runner (binary-codesign.mjs). + - name: Install rcodesign (ad-hoc sign darwin binaries) + run: | + set -euo pipefail + RCODESIGN_VERSION=0.29.0 + asset="apple-codesign-${RCODESIGN_VERSION}-x86_64-unknown-linux-musl.tar.gz" + curl -fsSL -o "/tmp/${asset}" \ + "https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign/${RCODESIGN_VERSION}/${asset}" + tar -xzf "/tmp/${asset}" -C /tmp + sudo install -m 755 \ + "/tmp/apple-codesign-${RCODESIGN_VERSION}-x86_64-unknown-linux-musl/rcodesign" \ + /usr/local/bin/rcodesign + rcodesign --version + - run: pnpm install --frozen-lockfile # Binary compile uses `bun build --compile` CLI (not Bun.build API). @@ -117,6 +132,21 @@ jobs: - name: Ensure zip and tar (per-platform binary archives) run: sudo apt-get update && sudo apt-get install -y zip + # bun build --compile leaves an invalid darwin linker signature. rcodesign + # re-signs those Mach-O files on the Linux runner (binary-codesign.mjs). + - name: Install rcodesign (ad-hoc sign darwin binaries) + run: | + set -euo pipefail + RCODESIGN_VERSION=0.29.0 + asset="apple-codesign-${RCODESIGN_VERSION}-x86_64-unknown-linux-musl.tar.gz" + curl -fsSL -o "/tmp/${asset}" \ + "https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign/${RCODESIGN_VERSION}/${asset}" + tar -xzf "/tmp/${asset}" -C /tmp + sudo install -m 755 \ + "/tmp/apple-codesign-${RCODESIGN_VERSION}-x86_64-unknown-linux-musl/rcodesign" \ + /usr/local/bin/rcodesign + rcodesign --version + - run: pnpm install --frozen-lockfile # Binary compile uses `bun build --compile` CLI (not Bun.build API). diff --git a/docs/agents/publish.md b/docs/agents/publish.md index ffe0ec412..f53e63e2b 100644 --- a/docs/agents/publish.md +++ b/docs/agents/publish.md @@ -114,6 +114,7 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run - **GitHub Release**:`contents: write` + `GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`(stable / channel 均需) - **Node 版本**:24(npm 11.5+ 才支持 OIDC token 交换) - **Bun**:`oven-sh/setup-bun`,版本钉死在 workflow 中 +- **darwin 签名**:编完后 `binary-codesign.mjs` 对 darwin Mach-O 做 ad-hoc 重签,并按 Apple 的方式重算每一页 SHA-256(末页不补零)。对不上就中止发布。macOS runner 额外跑 `codesign --verify --strict`。Linux runner 用 `rcodesign 0.29.0 sign`;不要用 `rcodesign verify`(会拒绝 Apple 已通过的 ad-hoc 签名)。漏签时 Apple Silicon 会在启动时 SIGKILL - **Actions 版本**:checkout/setup-node/pnpm-action 均为 v6(Node 24 兼容) - **npm 配置**:当前 release tooling 发布的包(`bailian-cli-core` / `bailian-cli-runtime` / `bailian-cli-commands` / `bailian-cli` / `knowledge-studio-cli`)的 Trusted Publisher 指向 `modelstudioai/cli` 的 `publish.yml`;新增发布包时同步 npm Trusted Publisher @@ -160,4 +161,5 @@ node tools/release/publish-channel.mjs --channel test --knowledge --dry-run | CI 用 Node 22(npm 10)跑 publish | npm 10 不支持 OIDC token 交换,publish 报 404 | | stable 发布前没有升级版本号 | 所选发布集合的版本已全部存在于 npm,CI 明确报错并要求先升级版本号 | | channel job 缺少 `contents: write` | `gh release create` 失败 | +| darwin 二进制编完后没有 ad-hoc 重签 | Apple Silicon / macOS 27 对失效的 linker 签名直接 SIGKILL(`Killed: 9`) | | stable 未先推 tag 就建 Release | `--verify-tag` 失败 | diff --git a/tools/release/lib/binary-build.mjs b/tools/release/lib/binary-build.mjs index 6e9b1247e..a5c51b09e 100644 --- a/tools/release/lib/binary-build.mjs +++ b/tools/release/lib/binary-build.mjs @@ -21,6 +21,7 @@ import { rollingManifestChannelId, rollingManifestFileName, } from "./binary-options.mjs"; +import { signDarwinAdhoc } from "./binary-codesign.mjs"; import { ensureTar, tarOne } from "./binary-tar.mjs"; import { ensureZip, zipOne } from "./binary-zip.mjs"; @@ -166,6 +167,8 @@ function compileOne({ bunTarget, os, arch, exe }, version, outdir, entry) { if (result.stderr) process.stderr.write(result.stderr); // Bun 1.2.19 writes windows-x64 .exe with mode 000 on Unix hosts (oven-sh/bun#21308). chmodSync(innerPath, 0o755); + // Sign after chmod. Bun's linker signature does not match the final Mach-O bytes. + if (os === "darwin") signDarwinAdhoc(innerPath, { log }); return { innerName, innerPath, os, arch, exe }; } diff --git a/tools/release/lib/binary-codesign.mjs b/tools/release/lib/binary-codesign.mjs new file mode 100644 index 000000000..9321b7865 --- /dev/null +++ b/tools/release/lib/binary-codesign.mjs @@ -0,0 +1,162 @@ +/** + * Ad-hoc re-sign Bun-compiled darwin Mach-O binaries. + * + * `bun build --compile` leaves a linker signature (`Identifier=a.out`, + * `adhoc,linker-signed`) whose page hashes do not match the final file. + * macOS 27 SIGKILLs that binary at launch (exit 137). Re-signing after + * compile covers the bytes that actually ship. + * + * darwin hosts use `codesign`, then `codesign --verify --strict`. + * Linux/Windows release runners use `rcodesign sign`, then check each + * CodeDirectory page hash in-process. `rcodesign verify` is not a gate: + * 0.29.0 rejects ad-hoc signatures Apple accepts (`CMS error`). + */ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; + +export const RCODESIGN_VERSION = "0.29.0"; + +function defaultLog(message = "") { + process.stdout.write(`${message}\n`); +} + +/** Command used to replace the linker signature. `platform` is the build host, not the target. */ +export function adhocSignPlan(platform = process.platform) { + if (platform === "darwin") { + return { + command: "codesign", + args: ["--force", "--sign", "-", "--identifier", "bl"], + }; + } + return { + command: "rcodesign", + args: ["sign"], + }; +} + +const LC_CODE_SIGNATURE = 0x1d; +const CSMAGIC_EMBEDDED_SIGNATURE = 0xfade0cc0; +const CSMAGIC_CODEDIRECTORY = 0xfade0c02; +const CSSLOT_CODEDIRECTORY = 0; +const CDHASH_SHA256 = 2; + +/** + * Recompute CodeDirectory page hashes the way Apple does for ad-hoc Mach-O: + * each 4KiB page is SHA-256 of the bytes that exist, with no zero padding. + * Bun's linker signature matches the padded last page and fails this check. + * + * @param {string} binaryPath + */ +export function verifyAdhocPageHashes(binaryPath) { + const data = readFileSync(binaryPath); + const magic = data.readUInt32LE(0); + const littleEndian = magic === 0xfeedfacf; + if (!littleEndian && magic !== 0xcffaedfe) { + throw new Error(`${binaryPath} is not a thin 64-bit Mach-O`); + } + const read32 = littleEndian + ? (offset) => data.readUInt32LE(offset) + : (offset) => data.readUInt32BE(offset); + const commandCount = read32(16); + let commandOffset = 32; + let signatureOffset = 0; + for (let commandIndex = 0; commandIndex < commandCount; commandIndex++) { + const command = read32(commandOffset); + const commandSize = read32(commandOffset + 4); + if (command === LC_CODE_SIGNATURE) signatureOffset = read32(commandOffset + 8); + commandOffset += commandSize; + } + if (!signatureOffset) throw new Error(`${binaryPath} has no LC_CODE_SIGNATURE`); + const readBlob32 = (offset) => data.readUInt32BE(signatureOffset + offset); + if (readBlob32(0) !== CSMAGIC_EMBEDDED_SIGNATURE) { + throw new Error(`${binaryPath} code signature superblob is invalid`); + } + const blobCount = readBlob32(8); + let directoryOffset = 0; + for (let blobIndex = 0; blobIndex < blobCount; blobIndex++) { + const blobType = readBlob32(12 + blobIndex * 8); + const blobOffset = readBlob32(12 + blobIndex * 8 + 4); + if (blobType === CSSLOT_CODEDIRECTORY) directoryOffset = blobOffset; + } + if (!directoryOffset) throw new Error(`${binaryPath} has no CodeDirectory`); + const directory = signatureOffset + directoryOffset; + if (data.readUInt32BE(directory) !== CSMAGIC_CODEDIRECTORY) { + throw new Error(`${binaryPath} CodeDirectory magic is invalid`); + } + const version = data.readUInt32BE(directory + 8); + const hashOffset = data.readUInt32BE(directory + 16); + const codeSlotCount = data.readUInt32BE(directory + 28); + let codeLimit = data.readUInt32BE(directory + 32); + const hashSize = data[directory + 36]; + const hashType = data[directory + 37]; + const pageShift = data[directory + 39]; + if (version >= 0x20300) { + const codeLimit64 = data.readBigUInt64BE(directory + 48); + if (codeLimit64 > 0n) codeLimit = Number(codeLimit64); + } + if (hashType !== CDHASH_SHA256 || hashSize !== 32) { + throw new Error(`${binaryPath} code signature is not SHA-256`); + } + if (codeLimit > data.length) { + throw new Error(`${binaryPath} codeLimit ${codeLimit} exceeds file length ${data.length}`); + } + const pageSize = pageShift === 0 ? codeLimit : 1 << pageShift; + for (let slot = 0; slot < codeSlotCount; slot++) { + const start = slot * pageSize; + const end = Math.min(start + pageSize, codeLimit); + const recorded = data.subarray( + directory + hashOffset + slot * hashSize, + directory + hashOffset + (slot + 1) * hashSize, + ); + const actual = createHash("sha256").update(data.subarray(start, end)).digest(); + if (!recorded.equals(actual)) { + throw new Error( + `${binaryPath} code signature page ${slot} does not match the file (stale ad-hoc signature)`, + ); + } + } +} + +/** + * Replace the Mach-O ad-hoc signature in place. + * Then recompute page hashes so a stale linker signature fails the build on Linux. + * On darwin, also run `codesign --verify --strict`. + * + * @param {string} binaryPath + * @param {{ log?: (message?: string) => void, platform?: NodeJS.Platform }} [options] + */ +export function signDarwinAdhoc( + binaryPath, + { log = defaultLog, platform = process.platform } = {}, +) { + const plan = adhocSignPlan(platform); + log(`adhoc sign via ${plan.command}`); + const result = spawnSync(plan.command, [...plan.args, binaryPath], { encoding: "utf-8" }); + if (result.error?.code === "ENOENT") { + const installHint = + platform === "darwin" + ? "codesign ships with the Xcode command line tools." + : `Install rcodesign ${RCODESIGN_VERSION} (apple-codesign) and retry.`; + throw new Error( + `${plan.command} not found on PATH. Darwin binaries need an ad-hoc signature before packaging. ${installHint}`, + ); + } + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) { + throw new Error(`ad-hoc sign failed for ${binaryPath}`); + } + verifyAdhocPageHashes(binaryPath); + if (platform !== "darwin") return; + + const verify = spawnSync("codesign", ["--verify", "--strict", binaryPath], { encoding: "utf-8" }); + if (verify.stdout) process.stdout.write(verify.stdout); + if (verify.stderr) process.stderr.write(verify.stderr); + if (verify.status !== 0) { + const detail = (verify.stderr || verify.stdout || "").trim(); + throw new Error( + `codesign --verify --strict failed for ${binaryPath}${detail ? `: ${detail}` : ""}`, + ); + } +} diff --git a/tools/release/lib/binary-codesign.test.mjs b/tools/release/lib/binary-codesign.test.mjs new file mode 100644 index 000000000..4dfaffb8c --- /dev/null +++ b/tools/release/lib/binary-codesign.test.mjs @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vite-plus/test"; +import { adhocSignPlan, RCODESIGN_VERSION } from "./binary-codesign.mjs"; + +describe("darwin ad-hoc sign plan", () => { + test("darwin hosts use codesign and replace the linker signature", () => { + expect(adhocSignPlan("darwin")).toEqual({ + command: "codesign", + args: ["--force", "--sign", "-", "--identifier", "bl"], + }); + }); + + test("non-darwin hosts use rcodesign so Linux CI can sign Mach-O", () => { + expect(RCODESIGN_VERSION).toBe("0.29.0"); + expect(adhocSignPlan("linux")).toEqual({ command: "rcodesign", args: ["sign"] }); + expect(adhocSignPlan("win32")).toEqual({ command: "rcodesign", args: ["sign"] }); + }); +});