Rust 跨平台构建与发布:macOS universal、vendored OpenSSL、musl 静态编译与签名审批

「SSH 终端是怎样做成的」系列第 8 篇。流水线和代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),主要是 .github/workflows/build.yml,只做删减。

要点

把一套 Rust 代码发布到 Windows、macOS 和 Linux,不只是能编译。MistTerm 在 Windows 和 macOS 上自带编译 OpenSSL(vendored),用 lipo 把 arm64 和 x86_64 合并成 macOS universal 二进制,从构建产物实测 glibc 最低版本,并为老服务器提供 musl 静态编译的 CLI。签名放在单独的任务里,需要维护者审批。

Rust 能编译到三个桌面平台,但「能编译」和「能发布」是两回事。一个 SSH 客户端要链接 C 写的加密库,要有原生安装包,要满足 macOS 的代码签名和公证,还要能在比 CI 镜像老好几年的服务器上运行。上一篇又加了一条要求:发布物必须以客户端能验证的方式签名,而签名私钥绝不能暴露给构建任务。

按任务来:Linux 以外自带编译 OpenSSL,从产物里量出 glibc 下限,给老服务器的 musl 静态 CLI,macOS universal 二进制(磁盘上 Mist 和 mist 会撞名),以及构建机碰不到的签名任务。

发布流水线:任务与依赖
发布流水线:任务与依赖

触发条件与任务图

流水线在推送 v* 标签时运行,也可以手动触发做测试构建。标签构建通过一个环境变量把自己标记为正式发布,build.rs 会检查它(第 7 篇):

MIST_DIST_CHANNEL: ${{ startsWith(github.ref, 'refs/tags/v') && 'github-release' || '' }}

各任务组成一条流水线:

  1. preflight:检查标签、Cargo.toml 和 macOS Info.plist 的版本号是否一致,并自测清单生成脚本。版本不一致的话,装好的新版会把自己当成旧版,更新提示反复出现。
  2. platform(Linux、Windows、macOS)和 cli-linux(静态构建)并行执行,都设了 fail-fast: false,一个平台失败不会掩盖其他平台的结果。
  3. manifest:根据构建产物计算 SHA256SUMS 和 latest.json。
  4. sign:用 minisign 给两者签名。这是唯一能接触到私钥的任务。
  5. release:发布 GitHub Release。
  6. mirror:本该上传到 mistlab.dev,目前处于关闭状态(if: … && vars.MIST_MIRROR_ENABLED == 'true'),因为还没实现。这就是第 7 篇里 mistlab.dev 清单地址返回 404 的原因。

两个构建任务开头都有同一道关卡:打标签的提交必须是 origin/main 的祖先,不能从没合并的分支直接发版。

if git merge-base --is-ancestor "$TAG_SHA" origin/main; then
  echo "✅ Tag commit is on main"
else
  echo "::error::Release tag ($TAG_SHA) is not on origin/main ($MAIN_SHA). Merge to main before tagging."
  exit 1
fi

OpenSSL:Linux 用系统的,其他平台自带

SSH 层用的是 ssh2(libssh2 的绑定),它需要一个加密后端。Cargo 特性 vendored-openssl(转发到 ssh2/vendored-openssl)会从源码编译 OpenSSL 并静态链接。矩阵按平台决定是否启用:

平台 Runner OpenSSL 说明
linux-x86_64 ubuntu-24.04(固定) 系统 mold 链接器;单元测试和集成测试在这里跑
windows-x86_64 windows-latest 自带编译 MSVC 目标;Inno Setup 安装包 + 便携版 zip
macos-universal macos-latest 自带编译 arm64 + x86_64 用 lipo 合并;签名并公证

Windows 上没有可供链接的系统 OpenSSL。macOS 自带的是供系统自己使用的 LibreSSL,不为第三方应用提供稳定的 OpenSSL ABI。自带编译把这两个问题都绕开了。Linux 上链接发行版的 OpenSSL 是惯例,构建也更快。

测试只在 Linux 这一路跑,源码注释的解释是 mold 加系统 OpenSSL 的组合最快。Linux runner 固定为 ubuntu-24.04 而不是 ubuntu-latest,注释写明 ubuntu-latest 从 2026-10-19 开始迁移,而 Ubuntu 26 还没有验证过。

glibc:怎样测出二进制真实的最低版本要求

在 Ubuntu 24.04 上构建的 Linux 程序,会引用这个版本 glibc 的符号版本,在 glibc 更旧的系统上启动不了。清单生成脚本不靠猜,而是实测:扫描打包好的程序里出现的 GLIBC_x.y 版本字符串,记下最高的那个。

def max_glibc_in_tarball(path, members=("Mist", "mist")):
    """Highest GLIBC_x.y symbol version referenced by the Linux binaries."""
    # … 对每个成员:GLIBC_RE.findall(data) → 取最大值

v1.2.26 的 Linux 图形版条目带有 "min_glibc": "2.39"。更新器拿它和本机 glibc 比较,不满足就提示手动下载,而不是装上一个启动不了的程序(第 7 篇)。

服务器往往老得多。cli-linux 任务为此用 musl 静态编译命令行客户端,覆盖 x86_64 和 aarch64,借助 cargo-zigbuild,用 Zig 作交叉编译的 C 工具链:

cargo zigbuild --locked --release --bin mist \
  --no-default-features --features vendored-openssl …

生成脚本反过来也强制这一点:静态命令行包里只要引用了任何 GLIBC_ 版本,清单生成就失败。

macOS universal 二进制怎么构建:大小写不敏感文件系统上的陷阱

macOS universal 程序是 aarch64-apple-darwin 和 x86_64-apple-darwin 两份构建用 lipo 合并成的一个 Mach-O 文件。流水线对两个可执行文件都这么做:图形界面的 Mist 和命令行的 mist。

这两个名字暴露了一个经典陷阱:macOS 默认文件系统 APFS 不区分大小写,Mist 和 mist 是同一个路径。构建循环先编命令行版并立刻改名为 mist-cli,最后编图形版,两者就不会互相覆盖:

# macOS 文件系统不区分大小写:Mist 和 mist 是同一个路径。先编 CLI 并改名 mist-cli,
# 最后编 GUI,否则 CLI 会覆盖 GUI。
for BIN in mist "$BIN_NAME"; do
  cargo build --locked --release --bin "$BIN" --target aarch64-apple-darwin …
  cargo build --locked --release --bin "$BIN" --target x86_64-apple-darwin …
  lipo -create -output "$U/$BIN.lipo" \
    "target/aarch64-apple-darwin/release/$BIN" \
    "target/x86_64-apple-darwin/release/$BIN"
  if [ "$BIN" = mist ]; then mv "$U/$BIN.lipo" "$U/mist-cli"; else …; fi
done
# GUI 二进制必须带诊断开关代码(只在 GUI 入口里)
grep -a -q -F mist-debug-log "$U/$BIN_NAME" || { echo "::error::… is not the GUI binary"; exit 1; }

最后那个 grep 检查的是结果,而不是相信构建顺序:一个只存在于图形版入口里的字符串,必须出现在要放进 Mist.app 的那个程序里。

之后应用包会签名并公证(scripts/sign-and-notarize-macos.sh)。公证之后,下载的应用才能被 Gatekeeper 直接打开,不弹警告。同一个签好名的应用包发布两次:

Windows:一次构建,产出安装版和便携版

Windows 这一路针对 MSVC 目标、使用自带编译的 OpenSSL,产出两个文件:用 Inno Setup 命令行编译器(ISCC.exe)生成的安装包,和一个便携版 zip。两者在更新清单里是两个独立条目(windows-x86_64-setup 和 windows-x86_64-portable),因为更新方式不同:安装版静默重跑安装程序,便携版替换自己的文件(第 7 篇)。

签名:单独的任务,单独的审批

签名是刻意隔离的。构建任务永远看不到私钥:它们只负责产出文件,manifest 任务计算哈希,只有运行在名为 release 的 GitHub environment 里的 sign 任务,才能从该环境的 secrets 里取到私钥:

sign:
  needs: manifest
  if: startsWith(github.ref, 'refs/tags/v')
  environment: release
umask 077
KEY="$RUNNER_TEMP/minisign.key"
printf '%s\n' "$MINISIGN_SECRET_KEY" > "$KEY"
trap 'rm -f "$KEY"' EXIT
TC="$(cat dist/latest.json.tc)"
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$KEY" -m dist/latest.json -t "$TC" -c "mistterm update manifest"
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$KEY" -m dist/SHA256SUMS -t "mistterm SHA256SUMS ${GITHUB_REF_NAME}" -c "mistterm checksums"

私钥文件以严格的权限创建,退出时删除。可信注释(-t)就是客户端会核对的 product channel version pub_date 字符串(第 7 篇),由写清单的同一个脚本算出,两者不会对不上。

发布之前,这个任务会用仓库里提交的公钥验证自己的输出,也就是编进客户端的那把公钥:

# 用仓库里提交的公钥(也就是客户端内置的那把)验证,私钥和公钥对不上就不发布。
minisign -V -p resources/update/minisign-primary.pub -m dist/latest.json
minisign -V -p resources/update/minisign-primary.pub -m dist/SHA256SUMS
grep -q "^trusted comment: $(cat dist/latest.json.tc)\$" dist/latest.json.minisig

如果 CI 里的私钥和客户端里的公钥对不上,发版就停在这里。否则发出去的更新,没有任何一个客户端能验证。

release 环境本身也配置成必须有人审批。通过 GitHub API 读到的保护规则是:

所以,即使是打对了标签的构建,也要停在签名之前,等维护者批准这次部署。单单推送一个标签(比如来自泄露的 CI 或部署令牌),不经那位审批人同意就拿不到签名。(prevent_self_review 是关闭的,推标签的维护者可以批准自己的部署。)

小结

签完名、发完包,这条线就停在这里。真正费时间的通常不是握手,而是宽字符排版、一次把通道写死的粘贴,或是绝不能让构建机碰到的签名任务。

常见问题

为什么在 Windows 和 macOS 上自带编译 OpenSSL?

Windows 没有可链接的系统 OpenSSL,macOS 也不给第三方应用提供稳定的 OpenSSL ABI。Linux 上链接发行版自带的 OpenSSL 是常规做法。

怎样查出 Linux 二进制需要的最低 glibc 版本?

扫描二进制里的 GLIBC_x.y 符号版本字符串,取最高的那个。MistTerm 的清单生成器把它记为 min_glibc。

macOS universal 二进制怎么构建?

分别为 aarch64-apple-darwin 和 x86_64-apple-darwin 构建,再用 lipo 合并成一个 Mach-O 文件。

为什么发布签名要放在单独的任务里?

这样构建任务永远看不到签名私钥。在 MistTerm 中只有 sign 任务能拿到私钥,它运行在需要维护者审批的 GitHub environment 里。

相关文章

← 第 7 篇 · 系列目录