在 CI 里给桌面版签名,而不把密钥交出去
每个平台都要你给二进制签名,没有一个让这件事愉快。这是搭建一条 Windows/macOS/Linux 签名流水线时的笔记 —— 私钥全程不出现在任何构建日志里。

在 CI 里给桌面版签名,而不把密钥交出去 🔏
在应用商店之外发布桌面软件,意味着你得自己签名:每个平台都签、在自动化流水线里签、而且签名材料全程不能暴露。下面是搭这条流水线时学到的东西的形状。
先签二进制,再打包 —— 顺序绝不能反
最费时间的错误是签错了产物。安装包是个容器;给容器签名并不等于给里面的东西签名,而每个平台的校验器都会走进去看。
正确顺序永远是:
- 构建未签名的二进制。
- 给每一个可执行文件和动态库单独签名。
- 把它们打进安装包、磁盘映像或归档。
- 给这个包本身签名。
弄反的后果是延迟且令人困惑的:安装包校验通过、应用装上了,然后某个嵌套的辅助二进制在启动时卡在校验上 —— 或者更糟,公证服务直接拒收整份提交,并点名一个你早就忘了存在的文件。
推论:签名之后绝不重新构建。 一个会重新编译而不是纯组装的打包步骤,会静默丢弃签名。如果你的打包工具想顺手构建,就把它拆开 —— 构建一次、签名、再从已签名的产物打包。我们自己流水线上那个决定性的修复正是这个:从已签名的二进制打包,不重新构建。
三个平台要三种不同的东西
Windows 要的是带时间戳的 Authenticode 签名。时间戳是那个被人跳过然后后悔的部分:没有它,证书过期时你的签名就不再验证通过 —— 包括已经分发出去的副本。你发布的每一个 .exe 和 .dll 都要签,不只是主程序。
macOS 要得最多,而且顺序严格:用 Developer ID 身份签名、开启 hardened runtime、带安全时间戳,然后公证,然后把票据装订上去。每一步都有自己"通过了但下一步失败"的方式。
两个值得提前知道的细节:
- hardened runtime 默认不开。 不开它的构建照样签得过、本地校验也通过,然后被公证服务拒绝。这个开关住在你的构建设置里,不在签名命令里 —— 这正是它被漏掉的原因。
- 公证日志是唯一真正的错误信息。 提交状态只告诉你失败了;日志才告诉你是哪个文件、为什么。每次都把它拉下来:
xcrun notarytool log <submission-id> \
--key ./AuthKey_XXXXXXXX.p8 --key-id XXXXXXXX --issuer <issuer-uuid>我们有两次为一个签名失败追了几个小时,而日志用一行就点了名 —— 一个没签名的嵌套辅助程序、一个缺失的安全时间戳、一个忘记关掉的调试权限。
Linux 什么都不要,而这本身是个问题:没有平台校验器,所以信任必须由你提供。发布校验和、用一个指纹公示在你站点上的密钥给它们签名,并且要知道跨架构打包是工具链最薄的地方。
把密钥挡在流水线之外
三条规则,按优先级:
优先用签名服务,而不是密钥文件。 云密钥库和托管签名让流水线去认证并请求一次签名,私钥从不在 runner 上存在过。这严格优于任何密钥处理纪律,因为你要保护的材料根本不在那里、无从泄漏。
必须用文件时,给它一个生命周期。 把它导入一个临时钥匙串或存储,用来自密钥管理服务的口令非交互地解锁,签名,然后销毁。在 macOS 上这顺带解决了交互式密码弹框卡住自动签名的问题 —— 一个带显式 key-partition-list 的专用钥匙串,是拿到无头签名器的可靠办法。
假设每条命令都会回显。 构建日志的可见度,常常比这些值的来源(密钥管理服务)高得多。绝不要把密钥作为命令行参数传递 —— 进程列表或详细日志都能抓到;用环境变量或文件,并在 CI 里标记为屏蔽。
宣布完成之前该验证什么
"签名成功了"和"签名是有效的"是两个不同的断言。它们之间的差距是验证,而验证很便宜:
- 检查签名实际声称了什么 —— 身份、时间戳、运行时标志 —— 而不只是"存在一个签名"。
- 在一台没有参与构建的机器上验证。构建主机信任一些用户机器不信任的东西。
- 走用户会走的那条下载路径。浏览器和网络传输会给文件打上你本地副本永远不会带的标记。
- 在完整往返之后验证:签名、打包、上传、下载、安装、启动。一张从未被装订的票据,只在用户离线时才失败 —— 也就是说,只在用户面前失败,永远不在你面前。
最后这点可以推广到代码签名之外。发布流水线的价值不在于每一步都报告成功,而在于陌生人下载到的产物就是你验证过的那个产物 —— 而唯一能确认这件事的办法,是自己当一次那个陌生人。