为什么要在 Linux 上构建全平台

传统做法是"用什么平台就在什么平台上打包":macOS 打 mac 包、Windows 打 win 包。这意味着 CI 里要养一台 macOS runner——贵、慢、还稀缺。

其实除了运行这一步,打包的绝大部分工作都是平台无关的:

  • electron-builder 本身能在 Linux 上产出 mac 的 .app、win 的 .exe/安装包;
  • macOS 的签名和公证过去必须用 codesign/notarytool(仅 macOS),但 rcodesign(Rust 实现的 apple-codesign)让这两步能在 Linux 上跑;
  • Windows 打包需要的 rcedit 依赖 wine,Linux 上装一个即可;
  • 唯一 Linux 上没有的是 hdiutil(造 .dmg),用 libdmg-hfsplus + 内核 HFS+ 驱动替代。

补齐这几块,一台 Linux runner 就能出全平台产物。下面按平台拆开讲。

整体流程

                ┌─────────────── GitHub-hosted Linux runner ───────────────┐
                │                                                            │
  matrix target │  electron-builder (跨平台打包)                              │
  mac-x64       │    ├─ mac:  产出 .app (zip-only)  ──┐                       │
  mac-arm64     │    │         afterPack  → rcodesign 签名 + 公证 + staple    │
  win-x64       │    │         afterAll   → mkdmg 造 .dmg + 签名 + 公证       │
  win-arm64     │    ├─ win:  wine + rcedit → 安装包                          │
                │    └─ 产出自动更新元数据 (latest*.yml + .blockmap)          │
                │                                                            │
                │  release guard: 独立校验签名 / 公证 / 差分包完整性           │
                └────────────────────────────────────────────────────────────┘

关键点:签名不放在打包之后单独做,而是塞进 electron-builder 的 hook 里。这样 .zip 打出来时里面已经是签好名的 .app,自动更新用的 sha512/size/blockmap 由 electron-builder 原生算出,不需要事后重新压缩、重新生成元数据。

在 Linux 上,先强制 mac 目标只打 zip、并关掉 electron-builder 自带的签名/公证(交给 hook):

# dist-signed.sh:Linux host 上构建 mac target
export CSC_IDENTITY_AUTO_DISCOVERY=false
export EXTRA_EB_ARGS="-c.mac.notarize=false -c.mac.target=zip"

Windows:只差一个 wine

Windows 部分几乎没有坑。electron-builder 打 win 包时会调用 rcedit(改 exe 图标/版本信息),它跑在 wine 上。

唯一要注意的是:electron-builder spawn 的是 wine 这个命令,不是 wine64。只装 wine64/wine32 会得到两个 loader,但没有 /usr/bin/wine wrapper,直接 spawn wine ENOENT:

apt-get install -y --no-install-recommends wine wine64 wine32 \
  || apt-get install -y --no-install-recommends wine \
  || apt-get install -y --no-install-recommends wine64
# 兜底:确保 `wine` 命令一定存在
command -v wine >/dev/null 2>&1 \
  || { w="$(command -v wine64 || true)"; [ -n "$w" ] && sudo ln -sf "$w" /usr/local/bin/wine; }
wine --version || true   # 打包前先冒烟验证

macOS:重头戏

mac 产物需要四件事:签名 → 打 .dmg → 公证 → staple(装订公证票据),再加上自动更新元数据。逐个看。

用 rcodesign 签名——entitlements 必须逐个 scope

在 macOS 上,codesign 会把主 bundle 的 entitlements “继承"给内嵌的二进制。rcodesign(≥0.22)不会:未 scope 的 --entitlements-xml-file 只作用于 .app 的主可执行文件。

这是最隐蔽的坑。如果内嵌了别的 Mach-O(我们的场景里有 sandbox 运行时 podmanmicrosandboxvfkit,还有 Electron 自己的 helper apps),而你只给了一份全局 entitlements,那么:

  • --for-notarization 会用空 entitlements 加固(hardened runtime)每一个内嵌二进制;
  • 运行时它 dlopen 一个签名不同的 dylib 时,library validation 直接拒绝;
  • 更坑的是 Electron 的 helper(Renderer/GPU/Plugin)拿不到 allow-jit,V8 在 Isolate::Initialize 处 SIGTRAP,应用启动即白屏——而公证不会报错,因为缺 entitlement 不是公证检查的东西。

正确做法:枚举 .app 里每一个内嵌 Mach-O,用 路径:plist 的形式逐个 scope。

// rcodesign 的 scope 语法:--entitlements-xml-file <bundle 相对路径>:<plist>
export function buildSignArgs(appPath, secrets, buildDir, nestedEntitlements = []) {
  const scoped = nestedEntitlements.flatMap(({ relPath, plist }) => [
    "--entitlements-xml-file",
    `${relPath}:${plist}`,
  ]);
  return [
    "sign",
    "--p12-file", secrets.p12Path,
    "--p12-password", secrets.p12Password,
    "--for-notarization",
    // 这份 unscoped 的只覆盖 .app 主可执行文件
    "--entitlements-xml-file", path.join(buildDir, "entitlements.mac.plist"),
    ...scoped,          // 每个内嵌二进制一条
    appPath,
  ];
}

枚举内嵌二进制时,别忘了 Contents/Frameworks/*.app 里的 Electron helper——它们是独立的嵌套 bundle,同样需要 JIT entitlements:

// 每个 helper 的主可执行文件都要 scope 上带 allow-jit 的 mac plist,
// 否则渲染进程白屏
for (const bin of findHelperExecutables(appPath)) {
  out.push({ relPath: rel(appPath, bin), plist: entitlements.mac });
}

教训:用魔数(magic bytes)发现 Mach-O,而不是硬编码文件名列表。将来多打一个 helper,也能自动被签到,不会静默漏签。

打自动更新用的 .zip——别让 7-Zip 解引用符号链接

Electron 的 .app 里,framework 用符号链接组织版本目录(Electron Framework.framework/Versions/Current → A)。压缩成 .zip这些符号链接必须保留

electron-builder 在 macOS 上会用原生 zip 保留符号链接,但在 Linux 上默认走 7-Zip,而 7-Zip 会解引用符号链接,把 .framework 结构压坏。装出来的包能用,但自动更新时 Squirrel/ShipIt 会报 bundle format is ambiguous (could be app or framework)

修复:用 pnpm patch 把 use7z 判断里的平台从只判 darwin 扩展到也判 linux,让 Linux 也走 Info-ZIP 的 zip -y(它同样保留符号链接):

- const use7z = !(process.platform === "darwin" && format === "zip" && options.preserveSymlinks);
+ const use7z = !((process.platform === "darwin" || process.platform === "linux")
+   && format === "zip" && options.preserveSymlinks);

造 .dmg——内核 HFS+ 挂载 + cp -a

Linux 没有 hdiutil。方案:建一个 HFS+ 空镜像,用内核 hfsplus 驱动挂载,把 .app 拷进去,再用 libdmg-hfsplus 转成压缩 UDIF。

两个反直觉的细节:

  1. 必须用 cp -a,不能用 rsync -aHAX rsync 在内核 hfsplus 上同步 com.apple.FinderInfo xattr 会硬失败(lremovexattr ENOENT,exit 23)。而 cp -a 容忍它,且完整保留符号链接、执行权限、字节内容(代码签名嵌在 Mach-O 里,所以内容不变签名就不变)。
  2. 不能用 libdmg 的 hfsplus addall 写文件——它会破坏内嵌的 Mach-O 签名和 framework 符号链接,公证报 “The signature of the binary is invalid”。libdmg 只用来做只读的 dmg dmg 转换(压缩)。
# mkdmg.sh 核心
raw="$tmp/image.hfs"
du_mb="$(du -sm "$APP" | cut -f1)"
size_mb=$(( du_mb + du_mb / 2 + 256 ))   # HFS+ block 对齐 + 大量小文件,留足余量
dd if=/dev/zero of="$raw" bs=1M count="$size_mb" status=none
mkfs.hfsplus -v "$VOL" "$raw" >/dev/null

# 内核挂载 + cp -a 保签名/符号链接
sudo mount -t hfsplus -o loop,rw "$raw" "$mnt"
sudo cp -a "$APP" "$mnt/"
sudo ln -s /Applications "$mnt/Applications" || true
sync && sudo umount "$mnt"

# libdmg 只做只读压缩转换
dmg dmg "$raw" "$OUT"

依赖:hfsprogs(提供 mkfs.hfsplus)、内核 hfsplus 模块(可能要装 linux-modules-extra-$(uname -r)modprobe)、以及自己编译的 libdmg-hfsplus(Mozilla fork,能在 OpenSSL 3 上编)。

公证 + staple

签好的 .app.dmg 都要提交 Apple 公证并 staple(把票据装订进产物,离线也能过 Gatekeeper)。rcodesign 一条命令搞定,注意公证是网络往返,加一次重试:

export function notarizeStaple(target, ascKeyPath) {
  const submit = () =>
    execFileSync("rcodesign",
      ["notary-submit", "--api-key-file", ascKeyPath, "--staple", target],
      { stdio: "inherit" });
  try { submit(); }
  catch { submit(); }   // 5xx / timeout 常见,重试一次;硬拒绝重试也会再失败,只多一次往返
}

.dmg 是容器不是 Mach-O,所以签它时不要--for-notarization/hardened runtime,只做普通 Developer ID 签名。

自动更新元数据——别把 .dmg 弄丢了

强制 -c.mac.target=zip 有个副作用:electron-builder 不再把 .dmg 写进 latest-<channel>-mac.yml。而 .dmg 是给用户手动下载的产物,必须在更新清单里列出。

想在 afterAllArtifactBuild hook 里补写 yml 是不行的——这个 hook 在 electron-builder 写 yml 的 finalize 阶段之前触发,那时 yml 还不存在。正确做法是等 electron-builder 完全退出后,在构建脚本里补写:

# electron-builder 退出后,所有 *-mac.yml 已落盘,再把 .dmg 追加进去
node "$SHELL_DIR/scripts/add-dmg-to-mac-yml.mjs" "$SHELL_DIR/dist"

差分更新的机制也要理解对:mac 的 .zip旁挂的 .zip.blockmap 做差分(electron-updater 会拉 <url>.blockmap 跟本地 diff)。electron-builder 只有在 blockmap 内嵌进产物时(NSIS-web / AppImage)才往 yml 写 blockMapSize;旁挂 blockmap 时故意不写。所以校验差分包别去查 blockMapSize,而是查:旁挂的 .blockmap 存在 + yml 里引用了 .zip + 有 sha512

一道 release guard 兜底

上面每一步都可能静默退化(漏签、丢 dmg、符号链接被压坏)。加一道独立的守卫,用 rcodesign 从产物反向验证,任何一条不满足就 fail:

  • .app 主 Mach-O 有 CMS Signature(确实签了 Developer ID);
  • .dmg 同时有 CMS SignatureTicket(签了名 + 公证票据已 staple);
  • .zip 里至少有一个 .framework/Versions/Current 是符号链接(证明没被 7z 解引用);
  • 旁挂 .blockmap 存在,且每个 *-mac.yml 都引用了 .zip(带 sha512)和 .dmg
zipinfo "$zip_path" | grep -qE '^l.*\.framework/Versions/Current' \
  || guard_fail ".zip 解引用了 framework 符号链接 —— 自动更新会报 bundle format is ambiguous"

注意事项清单

  1. rcodesign 不继承 entitlements——每个内嵌 Mach-O(含 Electron helper)都要逐个 scope,否则轻则运行时 library validation 失败,重则白屏,且公证不会报错
  2. Linux 上 electron-builder 默认用 7z 压 mac zip,会毁掉 framework 符号链接 → 自动更新 “bundle format is ambiguous”。patch 成走原生 zip
  3. 造 .dmg 用 cp -a + 内核 HFS+ 挂载,别用 rsync -X(内核 hfsplus xattr 硬失败),别用 libdmg addall(毁签名)。
  4. .dmg 不加 hardened runtime;.app 及内嵌二进制才加。
  5. 签名塞进 hook(afterPack 签 .app、afterAll 造/签 .dmg),让 .zip 和更新元数据原生正确,免去事后重压 / 重算。
  6. 补写 yml 要在 electron-builder 退出之后,不能在 afterAll hook 里(那时 yml 还没写)。
  7. 公证加一次重试;签名材料(p12 / ASC key)从 base64 环境变量落到临时文件,用完即删,永远不要提交进仓库或走 argv
  8. wine 要装 wrapper 命令本身,不只是 loader。

一个改不了的局限

这套流水线是纯构建的:Linux runner 造得出 mac/win 产物,但跑不起来。像"白屏"“bundle format ambiguous"这类问题,都是升级到真机才暴露的——CI 全绿不代表产物能启动。

所以发布 stable 之前,务必在真 macOS 硬件上挂载 .dmg、启动、过一遍 Gatekeeper。这一步机器代替不了。