上一篇我们把 Flutter iOS CI 里的 flutter build ipa 跑稳了,但团队发版仍卡在两件事:签名材料别过期、Archive 之后谁负责传 TestFlight。这篇只讲 Fastlane on Mac mini(主关键词锚点)——在独享 Mac mini M4 的 self-hosted Runner 上,用 fastlane match 管证书与描述文件,用 pilot(或 upload_to_testflight)完成 TestFlight upload,并把整条链路写进 mac mini ci。若你还在本机手点 Organizer 上传,或每次 CI 都重新生成 p12,这篇就是「编译之后」那一截。混合编译背景可看 告别 Xcode 卡顿;Runner 注册与节点 TCO 见 自建 macOS Runner。
1)能编 IPA 还不够:为什么必须上 Fastlane
xcodebuild archive 只解决「包在磁盘上」;TestFlight 要的是已签名、已上传、处理完成的构建。人工流程通常是:Xcode → Organizer → Distribute → 等处理——适合偶尔发版,不适合每周多次的 mac mini ci。更麻烦的是「今天谁的本子上有最新 p12」:同事休假、证书过期、描述文件少了一台测试机,都会在发版夜变成连环 call。
Fastlane 把「拉签名 → 编包 → 传 App Store Connect」收成可版本化的 Fastfile lane,和 GitHub Actions 的 job 一一对应。对独立开发者,它是把发版从「记得住步骤」变成「推 tag 就自动跑」;对小团队,它是把 Apple 开发者账号里最敏感的那部分材料,从聊天记录里挪到加密 Match 仓 + CI Secret。官方能力说明见 fastlane 文档;Match 与 pilot 分别见 match、pilot。
把 Runner 放在云端 Mac mini 而不是同事笔记本上的原因很现实:笔记本会合盖睡眠、会升级 macOS 导致 Xcode 变卦、会在出差时断网。机房里的 M4 机器 7×24 在线,fastlane match 导入的证书可以跨 job 复用,testflight upload 走机房出口,往往比家里宽带更稳定——这和「能不能编过」是同一类基础设施问题。
2)Fastlane iOS 发布流水线架构(云端 Mac mini)
典型拓扑:Linux 跑测试,Mac mini 跑签名与上传——Fastlane on Mac mini 是 release job 的唯一执行面。
Fastlane · Mac mini M4 CI
PR 在 Linux 测,release 打 tag
单元测试 · lint · Android
labels: macos, fastlane
match → build_app → pilot upload钥匙串 · 签名 · TestFlight
Runner 上建议持久化(避免每次 job 冷启动)
- login keychain + Match 证书
~/Library/Developer/Xcode/DerivedDatavendor/bundle· Ruby gems
3)云端 Mac 一次性环境(Fastlane 专用)
- Ruby: 系统 Ruby 或
rbenv;在仓库用Gemfile锁 fastlane 版本,CI 里bundle install --deployment。 - Xcode: 与 App Store Connect 要求的 SDK 对齐;
xcode-select -p写进 Wiki。 - 专用 CI 用户: 与日常 VNC 调试账号分开,避免「人工改 DerivedData + Runner 检出」互踩。
- 钥匙串: 创建
ci.keychain,开机解锁脚本写入 LaunchAgent 或 Runner 前置 step(密码放 GitHub Secret)。 - Match 仓库权限: Deploy key 或 PAT 只读/读写按角色分;
MATCH_PASSWORD进 Secret,禁止进日志。
第一次在云 Mac 上跑 bundle exec fastlane match development --readonly 验证只读拉证;再跑 release lane。出口带宽影响 testflight upload 耗时,节点选型见 帮助中心。
建议在 Wiki 里固定一份「发版机黄金快照」:fastlane --version、xcodebuild -version、ruby -v、bundle exec fastlane match --help 首屏输出。以后 CI 失败,先 diff 这份快照,能挡掉一半「环境悄悄变了」的冤枉。
4)fastlane match 在 Runner 上怎么「别每次重来」
Match 把 p12 与 mobileprovision 存在独立 Git 仓库(或云存储)。Runner 每次 job 的流程应是:解锁钥匙串 → match 同步 → build → 不删钥匙串。常见错误是 job 结束清理钥匙串,导致下一次又要重新导入、甚至触发 Apple 证书数量上限。
| 模式 | 行为 | 适用 |
|---|---|---|
match appstore + 持久钥匙串 | 二次 job 只更新过期描述文件 | 生产 mac mini ci |
每 job match nuke | 干净但慢、风险高 | 仅调试证书 |
| App Store Connect API Key | pilot 上传免 Apple ID 2FA | 无人值守 CI(推荐) |
API Key 上传流程见 Apple App Store Connect API;在 Fastlane 用 app_store_connect_api_key 传入 key_id、issuer_id、key_filepath,避免 pilot 卡在双因素。
实操里我会把 Match 仓与业务仓彻底分开:业务仓只放 Fastfile,Match 仓只放加密证书材料。CI 只给 Match 仓只读或受限写权限,避免一条误操作的 fastlane match nuke 把全队证书清空。新人入职只加 Apple Developer 账号,不一定要把 p12 发到他的微信——这是 Fastlane on Mac mini 对团队管理最值钱的地方之一。
若你同时维护 Ad Hoc 内测与 App Store 正式包,用不同 match type 分支,但同一台 Runner 尽量只跑一种 release lane,减少 profile 被错误覆盖的概率。描述文件里新加测试机后,需要一次有权限的人跑 match adhoc --force_for_new_devices,别在无人值守 CI 里自动 force,除非你很确定流程。
5)可抄的 Fastfile lane(build + TestFlight)
下面 lane 假设 iOS 工程已能本地 Archive;Flutter 项目请先完成上游 build ipa 或在此 lane 内调用你的构建脚本。
lane :release_testflight do
setup_ci if ENV['CI']
match(type: "appstore", readonly: is_ci)
build_app(
scheme: "YourApp",
export_method: "app-store",
output_directory: "./build"
)
pilot(
skip_waiting_for_build_processing: true,
distribute_external: false
)
end
GitHub Actions 里调用:bundle exec fastlane release_testflight,runs-on: [self-hosted, macos, fastlane]。敏感变量:MATCH_GIT_BASIC_AUTHORIZATION、MATCH_PASSWORD、APP_STORE_CONNECT_API_KEY(或 key JSON 路径)。
原生 Xcode 工程把 scheme 与 export_method 对齐;Flutter 工程可在 lane 开头调用 sh("fvm flutter build ipa", ...) 再 pilot 只上传已有 IPA。无论哪种,setup_ci 会在 CI 环境自动处理部分钥匙串与日志目录——官方说明在 fastlane 的 Continuous Integration 章节,值得通读一遍。
jobs:
release-ios:
runs-on: [self-hosted, macos, fastlane]
steps:
- uses: actions/checkout@v4
- run: bundle install --deployment
- run: bundle exec fastlane release_testflight
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.ASC_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.ASC_PRIVATE_KEY }}
6)Before / After:人工上传 vs Fastlane CI(同一仓库)
中等体量 App(单 scheme、无极端插件),我们在 Mac mini M4 16GB 上测「从 tag 到 TestFlight 处理中」的中位数——差异主要在人工等待与签名准备。
| 方式 | 首次发版 | 同证书二次发版 |
|---|---|---|
| 本机 Xcode Organizer 手传 | 35–50(含人工点选) | 25–35 |
| CI 仅 xcodebuild、无 Match 缓存 | 28–40 | 22–30 |
| Mac mini CI + Match + pilot | 22–32 | 12–18 |
7)GitHub Actions 与 Fastlane 分工
ci.yml:ubuntu-latest— 单测、Android、静态检查。release-ios.yml: 仅push: tags: ['v*'];runs-on: [self-hosted, macos, fastlane];步骤顺序:checkout → bundle install → fastlane release_testflight。- 并发: 同一台云 Mac 建议 release job 并发为 1,避免两把 lane 同时改钥匙串。
Runner 安装与费用模型见 GitHub Actions macOS Runner 节点 TCO。
发版通知建议接在 lane 末尾:slack 或 GitHub Actions workflow_run 总结——把 TestFlight 构建号、commit、耗时写进消息。失败时附上 fastlane/logs 路径或上传 artifact,比「谁去看下 CI 红了」省太多时间。若你用 Slack,注意 webhook 不要写进 Fastfile 明文,走 Secret。
8)常见问题:Fastlane CI 为什么卡住(how / fix)
match 报 git 权限 / could not clone
Why: Deploy key 未加入 Match 仓、或 HTTPS token 过期。
Fix: 用只读 key 跑 match --readonly 测克隆;CI 里 git config 与 MATCH_GIT_BASIC_AUTHORIZATION 用 base64 的 user:token。
signing failed / 描述文件不包含设备
Why: bundle id 变更、证书类型选错(development vs appstore)、Match 仓里 profile 过期。
Fix: 本地跑一次 match appstore --force_for_new_devices(慎用在 CI);Runner 上检查 security find-identity -v -p codesigning。
pilot / upload 失败、altool 报错
Why: Apple ID 2FA、API Key 权限不足、IPA 未用 app-store 方式导出。
Fix: 改用 App Store Connect API Key;对照 pilot 日志里的 ITMS 错误码;确认 export_method: "app-store"。
钥匙串弹窗 / CI 无 UI
Why: 新 p12 未设信任、访问控制要交互。
Fix: VNC 登录云 Mac 点一次「始终信任」;或在 CI 用 security set-key-partition-list;长期应 API Key 免 Apple ID 登录。
bundler / fastlane 版本漂移
Why: 未提交 Gemfile.lock,Runner 上 gem 与本地不一致。
Fix: 锁版本并缓存 vendor/bundle;lane 开头 fastlane --version 写入日志。
9)什么时候该为 Fastlane 单独租一台 Mac mini
| 情况 | 继续手传 / 蹭本机 | 云端 M4 + Fastlane CI |
|---|---|---|
| 每月 TestFlight ≥ 2 次 | 人工易漏步骤 | 建议 Match + pilot 固化 |
| 多人共用一个 Apple 开发者账号 | 证书互踢 | Match 单仓 + Runner 单钥匙串 |
| 已有 Flutter/Xcode 云构建 | 缺上传一环 | 同机追加 Fastlane lane |
| 仅偶尔内测 | 手传即可 | 日租跑通 lane 再决定月租 |
10)安全与合规:Match 仓、Secret 与日志
CI 日志是泄露高发区:MATCH_PASSWORD、API Key、git token 一旦打印到 Actions 日志,等于公开。务必在 Fastlane 里开启 --verbose 前想清楚,生产 lane 用默认日志级别;GitHub 上对 fork PR 关闭 Secret 注入。Match 仓开启 Git 加密(LFS 或仅加密文件)并限制只读 Deploy key 给 release workflow。
云 Mac 上建议定期轮换:Apple 发行证书临近一年期限、API Key 按季度换新、MATCH_PASSWORD 变更后全员同步。磁盘快照或备份时记住 Match 材料也在磁盘上——选独享裸金属而不是共享 VM,是为了减少「邻居」风险,见 Nuvcloud 裸金属 Mac mini 产品说明。
若公司有合规要求「构建机不能出境」,选节点时把 Runner 放在目标区域(新加坡、日本、美国东西部等),让 testflight upload 与源码检出都符合数据路径预期——这和选云服务器地区是同一类决策,不是 Fastlane 特有,但常被忽略。
11)FAQ
Fastlane 和 Xcode Cloud 怎么选?
Xcode Cloud 省心但定制与缓存策略受限;Fastlane on Mac mini 适合要固定环境、自建 Match 仓、与现有 GitHub Actions 深度集成的团队。
Match 仓可以放 GitHub Private Repo 吗?
可以,也是常见做法;注意加密与访问审计,p12 永远不应进业务代码仓。
Flutter 项目要先写这篇还是上一篇?
先 build ipa,再本篇做签名上传;也可在一条 lane 里串起来。
只有原生 iOS、没有 Flutter,本文适用吗?
完全适用。本篇 lane 示例是 Xcode scheme;你们若已按 云端 Xcode 编译 迁机,直接在同一个 Runner 上追加 Fastfile 即可。
托管 macOS Runner 分钟计费 vs 独享 Mac mini?
发版频次低、对队列不敏感可先用托管;当 fastlane match 要强钥匙串持久、且每月多次 TestFlight,独享 mac mini ci 的 TCO 往往更低,见 Runner TCO 一文中的算例思路。
签名上传也需要一台「一直在线」的 Mac
fastlane match 与 pilot 依赖钥匙串、Xcode 与稳定出口——笔记本合盖睡眠、同事本机证书互踢,都会让 CI 在半夜挂掉。我们把 release Runner 放在 Nuvcloud 独享 M4 Mac mini 上:SSH 维护 Match、VNC 处理一次性信任、按日/周/月扩展磁盘,和 mac mini ci 构建机可以是同一台,也可以是 release 专用机。
工程结论:Fastlane CI 最小可行方案(MVF)
若你每周至少向 TestFlight 交一版构建,且已在云端或本地能编出 IPA,最小方案是:
- 1 台 16GB Mac mini M4 作 release Runner(可与构建共用,但 job 并发建议为 1);
- 独立 Match 仓 + 持久
ci.keychain+Gemfile.lock锁 fastlane; - App Store Connect API Key 驱动 testflight upload,避免 2FA 卡 CI;
- 目标:48 小时内从空 Mac 跑通
match → build_app → pilot,并写入 tag 触发的 workflow。