很多团队在 GitHub Actions iOS CI 上遇到的第一个困惑是:明明已经是 Apple Silicon,为什么一条 Archive 还是要等半小时? 第一反应往往是换更快的 Mac、加更多核——但打开 Actions 日志你会发现,真正吃掉时间的常常是排队、依赖重装、冷 DerivedData、签名与上传,而不是 xcodebuild 里那几分钟编译。换句话说,GitHub Actions iOS CI 在 Mac 上慢,慢在运行模型,不是慢在芯片型号。
这篇只讲一件事:self-hosted macOS runner 为什么是 iOS 流水线里性价比最高的加速杠杆。我们会用一台独享 Mac mini M4 上的真实分工——持久磁盘、固定席位、可预测月费——对照托管 Runner 的 ephemeral 环境,说明典型中等规模工程如何把全链路从约 28 分钟压到约 9 分钟(以下为站内多组客户样本的中位数,非 SLA 承诺,请用自家仓库验证)。若你已在看节点 TCO,可先读 六地 Runner 横评;Flutter 与 Fastlane 细节分别见 Flutter iOS CI、Fastlane TestFlight。算力账单的大背景可对照 AI 算力基础设施战争——开发者侧最该优化的,往往是独享 Runner 席位而不是再买一个更贵的托管分钟包。
1)先排除三个误读:慢 ≠ M 芯片不行、慢 ≠ workflow 写错一行
在改 YAML 之前,建议先把日志里的耗时拆成四段:queued(排队)、bootstrap(拉代码 + 装依赖)、build(编译与 Archive)、release(签名 + 上传)。托管 macOS Runner 上,bootstrap 与 build 往往被「每次 job 一张白纸」放大——pod install 从零拉 spec repo、SPM 重新 resolve、DerivedData 目录为空,Archive 只能全量编译。排队则与组织内 macOS 并发配额、高峰时段共享池有关,和你在本地 M4 上「秒编」的体验完全不是同一套资源。
第二个误读是「加 runs-on: macos-latest 就等于有了一台自己的 Mac」。托管 Runner 按 GitHub Actions 计费规则 消耗 macOS 分钟,环境在 job 结束后销毁;你无法指望 ~/Library/Developer/Xcode/DerivedData 在 PR #412 里还在。第三个误读是把优化全押在「换 Xcode 版本」或「关几个编译警告」——这些有用,但在冷缓存面前通常只省几分钟。
du -sh ~/Library/Developer/Xcode/DerivedData。若每次 job 都是 0 或极小,你的主要矛盾就是没有持久 Runner 磁盘,而不是 scheme 配置。2)28 分钟到底花在哪:托管 Runner 的四段账单
我们汇总了十余个 Swift/UIKit 中等规模 App(单 scheme、CocoaPods、日更 main)在托管 macOS 上的 P50 耗时,大致如下。数字会因仓库体积、网络、是否用缓存 action 而波动,但结构比例很稳定:
| 阶段 | 典型耗时(托管) | 主要成因 |
|---|---|---|
| queued | 3–12 分钟 | 组织 macOS 并发、高峰共享池、多仓库抢席位 |
| bootstrap | 6–10 分钟 | 冷 pod install、SPM fetch、无 DerivedData |
| build / archive | 8–14 分钟 | 全量编译;偶发 Xcode 首次索引 |
| sign + upload | 3–6 分钟 | 证书导入、ASC 上传、处理等待 |
注意:build 往往不是最长的一段。当 bootstrap 每次从零开始时,你会感觉「Mac 很慢」——其实是重复劳动。这也解释了为什么有些团队本地 Archive 只要 4 分钟,CI 却要 25 分钟:本地有几个月沉淀的 DerivedData 和 Pods,CI 没有。
3)真正的加速点:独享 self-hosted runner + 持久磁盘
self-hosted macOS runner 的核心收益不是「CPU 更快」,而是同一台机器、同一块 SSD、同一个 macOS 用户反复跑 job:actions-runner 由 launchd 守护,job 之间不销毁磁盘。注册与维护方式见 GitHub 官方 self-hosted runner 文档。
在 Nuvcloud 等云端 Mac mini 上部署时,你还额外得到:机房稳定出口、7×24 在线、可按日租试跑再转月租——比把同事的 MacBook 当 Runner(合盖就断、系统升级就炸)可靠一个数量级。混合办公背景下,Windows 侧写代码、Mac 只负责 CI 的分工,可参考 告别 Xcode 卡顿。
GitHub Actions iOS CI · self-hosted 目标架构
Linux job:测试 · lint · Android
runs-on: [self-hosted, macos, ios-ci]
无排队 · 持久 DerivedData/Pods
签名材料跨 job 复用
跨 job 保留(不要在工作流里 rm -rf)
- DerivedData
- ~/Library/Caches/CocoaPods
- .build / SPM
- Match 钥匙串
- npm / bundler
把 Runner 放在独享 Mac mini 上时,queued 段往往直接归零——你买的是固定席位,不是和别人抢托管池。bootstrap 在第二次 job 起大幅下降:pod install 命中本地 spec 缓存,SPM 包已在 .build 里。build 走增量编译,P50 可从 10+ 分钟降到 3–5 分钟。若签名与 Match 也持久化(见 Fastlane 文),release 段同样更稳。
4)缓存策略:DerivedData、CocoaPods、SPM 与 actions/cache 的分工
self-hosted 上的第一原则:能留在磁盘上的就不要每个 job 重新下载。推荐分层如下。
- DerivedData:固定在 Runner 用户主目录,禁止在 workflow 末尾清理。大仓库可设
DERIVED_DATA_PATH指向独立目录并监控容量。 - CocoaPods:保留
Pods/与~/Library/Caches/CocoaPods;仅Podfile.lock变更时做完整 install。 - SPM:保留
.build与SourcePackages;CI 上与本地相同的resolved文件。 - actions/cache:仍可用于备份超大 DerivedData 到 GitHub 缓存服务,但在持久 Runner 上优先级低于本地盘——网络拉缓存往往比本地增量编译还慢。
Apple 对 CI 缓存的官方讨论分散在 Xcode 文档 与社区最佳实践中;对 iOS 团队而言,实操结论很简单:谁持有磁盘,谁掌握 P50 编译时间。
5)workflow 与 Runner 池:label 设计、并发与 1 台 vs 多台
在 workflow 里用明确 label 把 iOS job 打到你的 Mac mini 上,避免和其它实验性 Runner 混跑:
jobs:
ios-release:
runs-on: [self-hosted, macos, ios-ci]
concurrency:
group: ios-release-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Build archive
run: xcodebuild -scheme MyApp -configuration Release archive ...
并发:Archive 与导出 IPA 建议同一 Runner 上release 类 job 串行(concurrency 设为 1),避免两个 xcodebuild 抢签名与 DerivedData 锁。PR 检查若要与 release 并行,可用第二台 Mac mini 或给 PR job 单独 label(如 ios-pr)。
1 台 vs 多台:日更单 App、一条 release 流水线,一台 M4 通常够用;多 App、多 scheme 并行或「PR 检查 + 夜间全量」同时跑,按峰值加第二席位,比盲目加托管分钟数更可控。节点选型与月租对照见 Runner TCO 横评 与日本/新加坡专文。
6)M4 16GB vs 24GB:内存、磁盘与何时扩容
对 mac mini ci 而言,内存决定能否并行多个编译进程,磁盘决定缓存能留多久。经验法则:
| 配置 | 适合 | 注意 |
|---|---|---|
| M4 16GB · 默认盘 | 单 scheme、日更、Pods 中等、一条 release job | 监控 DerivedData;超大 Asset Catalog 可能吃满内存 |
| M4 24GB · 扩容盘 | Flutter + 原生混编、多 target、并行 PR + Archive | 为 DerivedData + Pods + 制品预留 ≥200GB 余量 |
若磁盘长期 >85%,不要靠「删缓存提速」——应扩容或拆 Runner,否则你会回到托管 Runner 式的冷启动噩梦。
7)Before / After:同一仓库托管 vs self-hosted(样本中位数)
| 阶段 | 托管 macOS Runner | Mac mini self-hosted |
|---|---|---|
| queued | 7 min | 0 min |
| bootstrap | 9 min | 2 min |
| build / archive | 11 min | 4 min |
| sign + upload | 5 min | 3 min |
| 合计 | ~28 min | ~9 min |
第二次及以后的 job,bootstrap 与 build 还会再降一截——这就是持久 Runner的复利。请用自家仓库跑 48 小时 A/B,不要直接套用表中数字签 SLA。
8)48 小时验证清单:从日租到月租的最短路径
- 在目标节点开 48h 日租 Mac mini(日本/新加坡/美西等按 Git 远端就近选,见 定价页)。
- 按官方文档注册 self-hosted runner,打 label
ios-ci,确认launchd守护进程在 reboot 后仍在线。 - 跑一条无缓存清理的 release workflow,记录四段耗时;再跑第二条 PR,对比 bootstrap 是否下降。
- 检查 DerivedData 目录体积是否随 job 增长后趋于稳定(增量编译生效)。
- 对比过去 30 天托管 macOS 分钟账单:若
M × 单价 > 月租且 P50 耗时满意,转月租锁席位。
一句话决策
- 若日志里每次 DerivedData 都是空的——先上 self-hosted runner,再谈换芯片。
- queued 经常 >5 分钟——固定席位比买更多托管分钟更有效。
- 48h 日租跑通 workflow 再月租;节点靠近 Git 与 ASC,而非只靠近开发者家。
9)常见疑问
GitHub Actions iOS CI 慢,是 Mac 性能不够?
多半不是。你本地 M4 编得飞快,CI 却慢,通常是在排队,或者每次 job 都是冷启动——Pods 重装、DerivedData 空的,等于从零再来一遍。换 macos-latest 这类标签,机器还是用完就扔的 ephemeral 环境,体感往往好不了多少。
self-hosted runner 安全吗?
自家私有仓库、团队成员固定,风险可控。GitHub 明确说过:别让 fork 来的 PR 自动跑 self-hosted job,陌生人代码别在你机器上执行。密钥放 GitHub Secrets,机器 SSH 定期换钥,和管一台生产机差不多。
能和托管 Runner 混着用吗?
当然可以,很多人就这么干:PR 检查扔 Linux,省 macOS 分钟;真要 Archive 再上 runs-on: [self-hosted, macos]。偶尔跑一次的杂活继续用托管也行,别让一台 Mac mini 扛所有 job。
Flutter 项目也适用吗?
适用。flutter build ipa 一样吃 DerivedData 和 Pods,道理没变。具体怎么配 workflow,看 Flutter iOS CI 那篇,Runner 架构跟这篇是一套。
非得买 Mac mini 吗?
不一定。你要的是一台只给你用的 macOS、磁盘能留着、别动不动就离线——自购放办公室、或者租云端裸金属都行。云端的好处是能按日试、换地区方便,不用自己折腾机房。
托管 macOS 分钟用得不多,还要 self-hosted 吗?
每月就几百分钟、偶尔编一次、排队也能忍,托管够用。要是 main 日更、好几条 release 线、或者经常全量编译,一般一两个月就会觉得「怎么又等了二十分钟」——那时候再考虑固定席位不迟。
macOS / Xcode 升级谁管?
self-hosted 得你自己管(或看云厂商镜像策略)。建议在 Runner 上把 Xcode 路径钉死(xcode-select),升级前先开分支跑一条试编译,别在大半夜 main 构建突然挂掉才发现 Xcode 变了。
28 分钟压到 9 分钟,能保证吗?
不能保证,文中数字是多家客户的样本中位数,不是承诺。小项目、或者 GitHub 缓存已经命中得很好,差距会小很多;超大 monorepo、每次 pod install --repo-update,该慢还是慢。拿自己仓库按上文 48 小时清单跑一遍,比看别人的表靠谱。
说白了:CI 慢到忍不了的时候,先想有没有固定席位、磁盘能不能留缓存,别急着再买一摞托管分钟。找台真 Mac 注册个 self-hosted runner,让 DerivedData 撑过第二个 job——差距通常一眼就能看出来。