Mac 客户端安装包发布流程:签名、Apple 检查与自动更新
状态:尚待完善
这篇文章目前是基于项目代码、Apple 官方文档和前期方案讨论整理的流程笔记。我们还没有在真实的公司发布环境里完整走通证书签名、Apple 检查、票据附加、S3 发布和客户端自动更新,所以文中的命令、网络白名单和异常处理还需要在真实发布后继续补充。

最近在考虑一个 Mac 桌面客户端的自动更新问题。一开始我以为这件事比较简单:CI 打出一个 DMG,上传到对象存储,再让客户端定时检查版本接口就可以了。
后来才发现,我把“文件下载”和“Mac 软件发布”混成了一件事。
把安装包放到 S3,只解决了员工从哪里下载。一个 Mac 安装包要想顺畅安装和自动更新,前面还有一整条信任链:谁发布的、安装包有没有被修改、Apple 是否检查过、用户离线时怎么验证、更新包能不能覆盖旧版本。
这些步骤少一个,最后都可能变成用户去“系统设置 → 隐私与安全性 → 仍要打开”。如果版本更新频繁,这个体验基本等于没有自动更新。
这里先把目前搞清楚的流程记下来。
先看完整流程
flowchart LR
A["构建 .app"] --> B["Developer ID 签名"]
B --> C["生成 DMG / ZIP"]
C --> D["上传 Apple 自动检查"]
D -->|Accepted| E["附加安全票据"]
D -->|Invalid| X["读取日志并修复"]
X --> A
E --> F["本地安全验证"]
F --> G["上传对象存储"]
G --> H["最后更新版本清单"]
H --> I["客户端发现新版本"]
I --> J["下载并校验"]
J --> K["退出、安装、重启"]
这条链路里有三个经常被混淆的东西:
- 签名:证明这个客户端确实由某个开发者发布,而且签名以后没有被修改。
- Apple 检查:Apple 自动扫描签名后的程序,确认没有已知恶意内容和明显的签名问题。官方叫 notarization。
- 票据:Apple 检查通过后签发的电子凭证,macOS 用它判断当前程序是不是 Apple 检查过的那一份。
签名不等于 Apple 检查。只签名、不做检查,用户仍然可能看到“Apple 无法检查是否包含恶意软件”的提示。
为什么内部软件也需要这套流程
内部使用并不会自动绕过 macOS 的 Gatekeeper。
员工从浏览器、聊天工具或内部网站下载一个应用时,macOS 会给文件记录“这是从外部下载的”。第一次打开时,系统会检查开发者签名和 Apple 票据。
不同处理方式的结果大致如下:
| 安装包状态 | 用户体验 |
|---|---|
| 没有签名 | 系统无法验证开发者,需要去安全设置里手动放行 |
| 有签名、没有 Apple 检查 | 系统可能提示无法检查恶意软件,仍然需要手动放行 |
| 已签名并通过 Apple 检查 | 第一次打开通常只有常规确认,不需要进入安全设置 |
对于五六个人的研发试用,手动放行可以接受。但如果客户端要频繁更新,或者交给几十名内部用户使用,这个成本会不断重复。
因此我的判断是:
未签名的包可以用于本地联调,但正式内部发布也应该完成签名和 Apple 检查。
如果公司所有 Mac 都由 IT 统一管理,也可以改由 IT 的设备管理平台安装和更新。那是另一条路线,不应该和客户端自更新混在一起。
Apple 到底检查了什么
Apple 检查并不是 App Store 人工审核。它不会评审业务功能、页面设计,也不决定软件能不能上架。
它主要做两件事:
- 检查程序是否包含已知恶意内容;
- 检查代码签名、时间戳和程序权限声明是否符合要求。
流程上需要把签名后的 ZIP、DMG 或 PKG 上传给 Apple。常用命令是:
xcrun notarytool submit DesktopApp.dmg \
--keychain-profile "notary-profile" \
--wait
命令会返回一个任务 ID 和最终状态。通过时状态是 Accepted;失败时是 Invalid,并提供检查日志。
Apple 官方文档写得比较明确:多数软件能在 5 分钟内完成,98% 能在 15 分钟内完成。但这不是严格 SLA,首次提交、文件很多、安装包很大或 Apple 服务异常时,仍可能等得更久。
所以发布流水线应该等待结果,但不应该让开发人员人工盯着。只有检查通过,才进入后面的 S3 发布阶段。
安全票据是怎样和安装包绑定的
一开始我以为 Apple 可能只是记录版本号和文件名。后来才发现,真正绑定的是代码内容的指纹。
一个 Electron 应用里不只有主程序,还会有 Helper、动态库和不同 CPU 架构的可执行文件。Apple 会为这些代码记录对应的 cdhash,可以简单理解为每段可执行代码的唯一指纹。
检查通过以后,Apple 签发一张包含这些指纹的票据。

flowchart LR
A["主程序"] --> H["代码指纹集合"]
B["Electron Helper"] --> H
C["动态库"] --> H
D["arm64 架构"] --> H
H --> T["Apple 签名的安全票据"]
T --> G["macOS Gatekeeper"]
A --> G
G -->|指纹一致| OK["允许运行"]
G -->|内容被修改| NO["拒绝运行"]
这意味着:
- 文件名相同没有用;
- 版本号相同也没有用;
- Apple 检查后只要再修改程序内容,指纹就会变化;
- 旧票据无法给修改后的程序继续使用。
所以发布顺序必须固定:
构建完成
→ 签名
→ Apple 检查
→ 附加票据
→ 最终验证
→ 上传 S3
检查完成以后,不能再去替换应用中的脚本、配置或资源。
在线票据和本地票据不是二选一
Apple 检查通过后,会自动把票据发布到 Apple 服务器。用户第一次打开软件时,Mac 可以联网查询。
开发者还可以执行:
xcrun stapler staple DesktopApp.dmg
xcrun stapler validate DesktopApp.dmg
第一条命令把票据附加到安装包,第二条验证附加是否成功。
成熟软件通常两边都保留:
- Apple 服务器保存在线票据;
- 最终分发的安装包携带一份票据。
这样即使用户的 Mac 不能访问 Apple,也可以从安装包里读取票据。对于公司内网、代理和安全软件比较复杂的环境,这一步更重要。
需要注意,DMG 和 PKG 可以直接附加票据,ZIP 本身不行。ZIP 的常见做法是先给里面的 .app 附加票据,再重新压缩。
受限网络里的发布方式
Apple 检查必须联网,因为要把编译后的安装包上传到 Apple。把命令换成 HTTP API 也没有区别,底层仍然需要访问 Apple 和其指定的临时存储。
如果公司 CI 完全不能访问外网,就不能在这台 CI 机器上完成 Apple 检查。
flowchart TB
A["内网 CI 构建"] --> B{"发布环境能否访问 Apple"}
B -->|允许受控访问| C["在 Mac Runner 上签名和检查"]
B -->|普通 CI 禁止联网| D["交给专用联网 Mac 或公司签名服务"]
B -->|产物禁止离开内网| E["无法做 Apple 检查"]
C --> F["验证后上传内部对象存储"]
D --> F
E --> G["IT 统一安装 / 用户手动放行"]
比较可行的设计是把构建和发布拆开:
内网 CI
负责:编译、测试、生成待发布产物
受控 Mac 发布机
负责:签名、Apple 检查、附加票据、最终验证、上传 S3
这台发布机不需要开放普通互联网,只需要经过审批访问 Apple 检查所需的地址。它还需要安全保存公司证书和检查凭据。
还有一个容易忽略的问题:上传给 Apple 的不是源代码,但确实是编译后的完整应用。因此安装包里不能包含密码、API Key 和环境密钥;同时需要确认公司安全政策是否允许把编译产物提交给 Apple。
安装包放到 S3 以后怎么管理
初期内部使用并不一定需要先做一个复杂的版本管理后台。
更简单的方式是:
- 每个版本使用不可变目录;
- 安装包上传后不允许覆盖;
- 上传并校验所有文件;
- 最后更新
latest-mac.yml; - 客户端只读取版本清单,不扫描整个目录。
releases/
├── 1.2.2/
│ └── macos-arm64/
│ ├── DesktopApp-1.2.2.dmg
│ └── DesktopApp-1.2.2.zip
├── 1.2.3/
│ └── macos-arm64/
│ ├── DesktopApp-1.2.3.dmg
│ └── DesktopApp-1.2.3.zip
└── channels/
├── internal/
│ └── latest-mac.yml
└── stable/
└── latest-mac.yml
latest-mac.yml 里至少会记录:
- 最新版本;
- 更新包地址;
- 文件大小;
- SHA-512 校验值;
- 发布时间。
发布时必须最后更新清单:
sequenceDiagram
participant CI as 发布流水线
participant S3 as 对象存储
participant Client as 客户端
CI->>S3: 上传带版本号的 DMG / ZIP
CI->>S3: 校验文件大小和哈希
CI->>CI: 验证签名和 Apple 票据
CI->>S3: 最后更新 latest-mac.yml
Client->>S3: 读取 latest-mac.yml
Client->>S3: 下载对应更新包
Client->>Client: 校验、等待用户重启安装
如果先更新清单、后上传安装包,客户端就可能看到一个还没有准备好的版本。这类错误很隐蔽,但真实用户一旦命中,就会表现为更新下载失败。
回滚也不应该覆盖旧文件。最简单的方式是重新把渠道清单指向上一个已经验证过的版本。
客户端自动更新应该做什么
以 Electron 客户端为例,客户端侧的流程一般是:
stateDiagram-v2
[*] --> Idle
Idle --> Checking: 启动或手动检查
Checking --> UpToDate: 没有新版本
Checking --> Downloading: 发现新版本
Downloading --> Ready: 下载和校验成功
Downloading --> Error: 下载或校验失败
Ready --> Installing: 用户确认重启
Installing --> [*]
Error --> Idle: 稍后重试
初期建议保持克制:
- 启动后检查一次,之后低频轮询;
- 自动下载,但不要自动打断当前工作;
- 下载完成后提示用户重启;
- 应用退出前停止后台进程,避免安装时文件仍被占用;
- 更新失败不能破坏当前可运行版本;
- 同一个版本重复检查和下载要保持幂等。
真正的自动更新不是“下载以后覆盖一下文件”。它至少要同时满足:
- 新包由同一个开发者身份签名;
- 新包已经通过 Apple 检查;
- 下载文件的哈希与清单一致;
- 安装失败后旧版本仍然可用;
- 更新过程中不能丢失用户正在进行的任务。
发布前检查清单
目前我会先按下面这份清单准备真实联调:
账号和权限
- 公司是否已有 Apple Developer Program 组织账号;
- 是否可以使用 Developer ID Application 证书;
- 是否有专门用于 Apple 检查的团队凭据;
- 凭据是否保存在 CI Secret 或受控钥匙串,而不是代码仓库。
网络和安全
- 发布机是否允许访问 Apple 检查服务;
- 是否允许上传编译后的应用;
- HTTPS 是否被代理拦截或替换证书;
- 安装包内是否包含密钥、内部地址或不应发布的数据。
构建和验证
- 代码签名验证通过;
- Apple 检查状态为
Accepted; -
stapler validate通过; - 在另一台干净 Mac 上完成首次安装;
- 在受限网络或离线环境验证本地票据;
- 从旧版本真实升级到新版本;
- 更新失败时旧版本仍然可以启动。
S3 发布
- 版本目录不可变;
- 安装包、更新包和校验信息完整;
- 更新清单最后发布;
- 保留上一个稳定版本;
- 上传权限只给发布流水线,客户端只有读取权限。
后续要补什么
这篇目前最大的缺口不是文档,而是还没有真实跑过。
后面完成第一轮发布以后,至少需要回来补充:
- 公司实际使用的证书申请和授权流程;
- 受限网络环境需要开放的具体地址;
- Electron Builder 的真实签名和 Apple 检查配置;
- 第一次检查和后续检查的真实耗时;
- DMG 首次安装与 ZIP 自动更新的完整验证结果;
- 证书过期、Apple 检查失败和 S3 发布失败的恢复方式;
- 一次从旧版本升级到新版本的完整日志。
这篇写下来,我最想记住的是:
S3 只是文件存放位置。Mac 客户端真正能够被顺畅安装和自动更新,依赖的是签名、Apple 检查、票据、不可变安装包和最后发布版本清单这一整条链路。
如果不知道这条链路,最容易出现的结果就是:CI 显示打包成功,S3 也有文件,但员工下载以后仍然打不开,或者每次升级都要重新去系统设置里放行。