| 项 | 内容 |
|---|---|
| 分享主题 | Fastlane + 自助提单台:把设备上架做成一条流水线 |
| 对应代码 | CAESFastlane(Fastfile / devices.txt / sign_sync.sh / web-form) |
| 方案文档 | 技术方案-Apple设备与证书自动化.md(v1.0,CLI 层) |
| 建议时长 | 20 分钟(讲解 15 + 演示 3 + 提问 2) |
| 状态 | 已上线运行,首版提交 55c20e9;自助层为后续演进 |
0. 一页速览
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 发起方式 | 找会跑脚本的人,改文件、开终端 | 内网表单自助提单,填 UDID + 使用者即可 |
| 单次设备落地 | 后台登记 + 逐份 Profile 手点重建 + 手工归档,约 20~30 分钟纯机械操作 | 审核台点一次「通过」,实测 3 分 43 秒 全自动跑完 |
| 人工步骤 | 5 步(登记 / 重建 / 下载 / 归档 / 通知) | 0 步(只有「审核」是人的判断) |
| 覆盖范围 | 依赖记忆,容易漏 App | 2 个 Team 共 10 个 Bundle ID × 2 类 Profile,一次跑满 |
| 出错定位 | 看底层堆栈猜 | 六步流水线日志逐行留档 + 失败一键重试 |
一句话总结:把「设备白名单变更」这件高频、机械、易漏的事,拆成 显式的文件契约 + 幂等的命令 + 有状态的自助台。
1. 为什么要做这件事
1.1 痛点不是”登记设备”,而是”登记之后”
设备登记本身只是 Apple 后台填一行 UDID。真正贵的是它的连锁反应:
设备白名单变了 → 所有绑设备的描述文件(Development / Ad Hoc)全部失效 → 每个 App、每种类型都要 Edit → 勾选新设备 → Generate → 重新下载 → 归档进证书仓库 → 构建机重装 → 重打包。
按本项目规模算:10 个 App × 2 类 = 20 份 Profile,每份 4 次点击 + 1 次下载 + 1 次归档,一次设备变更 ≈ 80 次点击,而且漏一份就是一个 App 装不上。
1.2 四个真实后果
| 现象 | 根因 |
|---|---|
| 新同事的机器装不上包,提示「未针对此设备配置」 | Profile 白名单还是旧的,设备登记了但没重建 |
构建报 No provisioning profile matching |
后台 Profile 名字与 ExportOptions.plist 引用名不一致 |
| 有人手工在后台 Edit 了一份同名 Profile | 脚本按 bundle_id 匹配时误删了别人的 Profile |
| 换团队要改代码 | 凭据切换靠改 Fastfile,存在串号(用错 Team)风险 |
1.3 目标(当时写进方案的验收线)
- 一条命令 / 一次点击完成 登记 → 重建 → 归档;
- 重复执行不产生重复设备、不产生多余提交(幂等);
- 多 Apple 账号 零代码切换;
- Profile 名字稳定(UUID 可变,名字不可变);
- 失败能定位、能重试,而不是”再跑一次试试”。
2. 方案骨架:六层,两条边界
1
2
3
4
5
6
7
8
① 配置层(入库) devices.txt / Appfile / Fastfile / Gemfile
② 凭据层(不入库) .env、.env.<team>、AuthKey_*.p8
③ 执行层(CLI) Fastfile:verify_auth / register / update_profiles …
④ 苹果云端 Devices 白名单、Provisioning Profiles
⑤ 分发层(CLI) sign_sync.sh:./profiles/ → Sign 仓库 → commit/push
⑥ 消费层(CI) auto_install_all_cer.sh 装证书、auto_build.sh 重打包
────────────────────────────────────────────────────────────
⑦ 自助层(v1.1 新增) web-form:内网提单 → 审核台 → 队列执行 → 实时日志
两条边界原则,是整个方案好维护的关键:
sign_sync.sh不做任何 Apple 侧操作,Fastfile不做任何 Git 操作。 二者只通过./profiles/这一个目录耦合 —— 文件即契约,任一侧都能单独替换。
这也是后来能顺利加一层 Web 自助台的原因:新层只负责「调用 CLI」,不碰 Apple、不碰归档逻辑。
3. 五个决定成败的实现细节
3.1 认证:三级降级,但只推荐第一级
1
2
3
4
5
ASC_KEY_ID + ASC_ISSUER_ID ? ── 是 ──► ① API Key 文件路径(开发机)
│否
API_KEY_ID + API_ISSUER_ID ? ── 是 ──► ② Key 内容 base64(CI 注入)
│否
────────► ③ Apple ID + 2FA(兜底,人值守)
- 为什么 API Key 优先:JWT 无状态鉴权,不受双因子与密码变更影响,可无人值守;Apple ID 方式一旦触发 2FA,自动化就断了。
- 踩坑:
ASC_KEY_FILEPATH=~/private_keys/x.p8直接透传会误报文件不存在 —— Ruby 的File.exist?不展开~,只有 Shell 会。必须先File.expand_path。
3.2 重建:update_profiles 的四件套参数
1
2
3
4
5
6
7
8
9
get_provisioning_profile(
development: true, # 类型开关(sigh 用布尔,match 才用 type:)
app_identifier: bundle_id, # sigh 一次只吃一个 → 必须循环
provisioning_name: dev_name, # ① 钉死 Apple 端名字
ignore_profiles_with_different_name: true, # ② 只动精确同名者,防误删
force: true, # ③ 删旧建新,把新设备一次性纳入
output_path: output_dir, # ④ 落盘到 ./profiles/
filename: "#{dev_name}.mobileprovision" # ⑤ 本地名 = 后台名,便于人工核对
)
逐个说人话:
| 参数 | 不写会怎样 |
|---|---|
① provisioning_name |
sigh 自动命名与 ExportOptions.plist 引用名漂移,构建期才炸 |
② ignore_profiles_with_different_name |
按 bundle_id 匹配并删除同 App 不同名的 Profile,误伤他人流程 |
③ force: true |
白名单停留在旧状态,新设备装机失败 |
④ output_path |
描述文件散落,归档脚本取不到 |
⑤ filename |
默认名含 bundle_id 和空格,无法与归档目录对应 |
关键认知:
force: true是「删除 + 新建」,UUID 必变、名字不变。 所以下游只能按 名字 引用,绝不能按 UUID —— 这一条要写进构建侧的契约,而不是靠口头约定。
3.3 路径:以 Fastfile 自身为锚点
1
2
3
4
5
# ❌ fastlane 执行时会 chdir 到 fastlane/,被叠加成 fastlane/fastlane/devices.txt
DEVICES_FILE = "./fastlane/devices.txt"
# ✅ 与 cwd 解耦
DEVICES_FILE = File.expand_path("devices.txt", __dir__)
推广规则:Fastfile 里引用项目内文件,一律 __dir__ 锚定,禁止 Dir.pwd。
3.4 多团队:--env 零改码,但有两个前提
1
2
bundle exec fastlane register # 默认 Team
bundle exec fastlane register --env caes # 切到 Team2(凭据覆盖)
加载时序决定了「不用改代码」:dotenv 在 Fastfile 求值之前 加载,所以 Fastfile 顶部 APP_IDENTIFIERS = ENV["…"] 这类常量天然跟随 --env。
两个高危前提:
.env.<team>必须填全部团队相关变量,漏一项就沿用基础.env→ Issuer 与 Key 串号 → 401;.gitignore必须写.env.*,只写.env会把新团队的凭据提交上去。
3.5 devices.txt:格式是契约,注释有陷阱
Fastlane 的实现顺序是「先 CSV.read 解析整个文件,再过滤 # 注释」,于是有三个反直觉约束:
| 约束 | 报错 |
|---|---|
首行必须是表头 Device ID<TAB>Device Name |
Please provide a file according to the Apple Sample UDID file |
| UDID 与名称之间必须是真实制表符 | 同上(整行变单列,列数校验失败) |
| 注释行里不能有裸双引号 | CSV::MalformedCSVError: Illegal quoting in line N(行号指向注释,极具误导性) |
提交前用与 Fastlane 一致的方式预校验,比跑到一半再失败便宜得多:
1
2
3
4
require "csv"
raw = CSV.read("fastlane/devices.txt", headers: true, col_sep: "\t",
skip_blanks: true, skip_lines: /^#/)
puts "解析到 #{raw.count} 台;表头 = #{raw.headers.inspect}"
4. 自助层:把「只有我会跑」变成「谁都能提单」
CLI 解决了”重复劳动”,但没解决”依赖人”。于是加了 web-form(Node 原生 http,零依赖):
1
2
3
4
5
6
7
8
内网同事 ① 提单(0.0.0.0:3000) ② 审核台(127.0.0.1:3001)
│ UDID / 使用者 / 型号 / 类型 │
├───────────────► pending ──────────────────────────────►│
│ (只落库,不执行任何命令) │ 通过 / 拒绝 / 批量通过
│ ▼
│ ③ 队列执行(全局单任务锁) approved → running → success / failed
│ clone → 注入凭据 → 追加设备 → register ×2 → push + sign_sync
└───────────────④ 我的提交(状态 / 耗时 / 失败原因 / 日志)◄────┘
4.1 设计要点
| 设计 | 为什么这么做 |
|---|---|
双端口 + 审核台只监听 127.0.0.1 |
提单可以随手发内网,执行入口永远在本机;端口硬编码,不接受环境变量覆盖 |
| 提交只入队,不触发命令 | 任意人能提单 ≠ 任意人能改 Apple 后台;审核是唯一的人工闸门 |
| 全局单任务锁 | 多任务并发会互相覆盖 devices.txt 和 ./profiles/,宁可选排队 |
| 批量通过 = 合并成一个任务 | 一次 clone、一次 commit、一次重建,10 台设备与 1 台设备成本几乎相同 |
| 状态机显式化 | pending → approved → running → success/failed,failed 可重试,rejected 可重提;服务重启时把 running/approved 判为失败并提示重试(孤儿回收) |
| SSE 实时日志 + jsonl 落盘 | 审核台能看滚动的六步日志;断线或事后回查用 /api/task/:id/log |
| 外部命令走参数数组 | spawn(cmd, args) 不经 shell,用户输入无法注入命令 |
| 重复拦截三层 | 提单时同 UDID 在 pending/running/success 中即拒;执行前批内查重;落盘前再查仓库两份文件(uuid.csv / devices.txt) |
4.2 六步流水线(每一步失败即整批失败、可重试)
| 步骤 | 动作 | 不留情面的设计 |
|---|---|---|
| 0/6 | 批内 UDID 预校验 | 批内重复整批拦下,不碰 Apple |
| 1/6 | git clone --depth 1 |
每任务独立 workspace,脏数据不跨任务 |
| 2/6 | 复制 .env* 凭据 |
凭据只在执行机,绝不进仓库 |
| 3/6 | 追加 uuid.csv + devices.txt |
任一 UDID 已存在 → 整批拦下,提示行号 |
| 4/6 | fastlane register(Team1) |
逐 App 输出 sigh 参数,便于核对 |
| 5/6 | fastlane register --env caes(Team2) |
同一份 Fastfile,零改码切号 |
| 6/6 | commit + push + sign_sync.sh |
Profile 按映射表 mv 进 Sign 仓库并推送 |
5. 演示

原始录屏 3 分 56 秒(
fastlane_apple_profile.mov),压缩为 43 秒、4.2 MB 的标注版 GIF;中段 Profile 重建部分以 20× 加速。 时间轴:0:00审核台总览 →0:06一键通过,六步流水线启动 →0:17Team1 完成、切 Team2 →0:26全量重建(加速) →0:34归档并推送 Sign 仓库。
6. 踩过的坑 · 可直接照抄的清单
| # | 坑 | 现象 | 结论 |
|---|---|---|---|
| 1 | Hash ≠ Token | undefined method 'in_house' for an instance of Hash |
手动赋 Spaceship::ConnectAPI.token= 时须 Token.from(hash:);把 Hash 直接传给 action 是正确用法 |
| 2 | chdir + 相对路径 |
文件明明存在却报不存在 | 一律 File.expand_path(..., __dir__) |
| 3 | ~ 不展开 |
.p8 路径校验失败 |
先 File.expand_path,再 File.exist? |
| 4 | CSV 先解析后过滤注释 |
注释里的裸 " 让整份清单不可用 |
注释禁用裸双引号;提交前 Ruby 预校验 |
| 5 | name 列表 reject(&:empty?) |
Profile 名与 Bundle ID 错位(最隐蔽) | 名称列表按位置对应,不能过滤空项,缺失项回退默认名 |
| 6 | force: true 改 UUID |
引用旧 UUID 的构建失败 | 构建侧 signingStyle=manual,按 name 引用 |
| 7 | App Store Profile 不绑设备 | 以为”没重建所以不生效” | 设备变更只重建 Dev + AdHoc,AppStore 无需重建 |
| 8 | 串号 | 401 但 Key 完全正确 | 多团队场景先核对 Issuer/Key 是否同源;verify_auth 打印三项值就是为了这个 |
401 的分层排查顺序(凭据问题,不是代码问题):本机时钟 → .p8 完整性 → Key ID 与文件名同源 → Issuer 与 Key 同账号 → Key 是否被撤销。
7. 效果与边界
7.1 数字
- 单次设备落地:约 4 分钟(Team1 2 个 App 约 40s,Team2 8 个 App 约 170s);
- 一次变更覆盖 20 份 Profile(10 个 Bundle ID × Dev/AdHoc),无需逐份点;
- 归档映射:38 项 → 13 个目录,
git status --porcelain守门,内容没变不产生空提交; - 失败可重试、日志可回查,交接成本从”找那个人”变成”看那个日志”。
7.2 当前边界(诚实清单)
| 边界 | 说明 |
|---|---|
devices.txt 全局共享 |
不随 --env 切团队,多 Team 设备无法隔离 |
| 映射表手工维护 | 新增 App 要同时改 Fastfile 与 sign_sync.sh 双数组,两处易脱节;CAESSupercharge/App、Qiji/CaesApp、Qiji/SIT 尚未纳入 |
| 只覆盖 Dev + AdHoc | Push 类证书与 AppStore 类仍在他处维护 |
| 无上限预警 | Dev/AdHoc 各 100 台上限,靠人工 list_devices 盘点 |
7.3 演进顺序(按收益)
- 双数组抽成单份
profiles.map映射文件 → 消除脱节; - 自助层接 CI / 企业微信机器人 → 结果主动播报;
- 上限阈值告警(≥90 台);
devices.txt按--env拆分 → 多 Team 设备隔离。
8. 三条可复用的经验
- 幂等 > 聪明。
force删旧建新、mv归档、porcelain守门、批内查重 —— 每个动作都设计成「重复执行结果一致」,比任何”增量更新”的巧妙逻辑都可靠。 - 把契约显式化。名字、目录、文件格式、访问入口,全部写死并文档化:
provisioning_name↔ExportOptions.plist、./profiles/↔sign_sync.sh、127.0.0.1↔ 审核台。显式映射可评审,自动推断迟早出事。 - 人工步骤要变成状态机。有状态、有日志、能重试,才谈得上交接;否则自动化只是把”只有他会”换成了”只有脚本会”。
9. Q&A 预案
| 问题 | 回答要点 |
|---|---|
为什么不用 match? |
证书分发已由 Sign 仓库承担,match 会引入第二套证书仓库与加密口令,职责重叠;sigh 只做「重建 + 下载」,更轻 |
| 为什么不让提单端直接执行? | 提单是开放入口,执行是特权操作。审核台只监听 127.0.0.1 + 状态机闸门,把”能提”与”能跑”分开 |
| 为什么坚持删旧建新而不是增量追加? | Apple API 对”向已有 Profile 追加设备”支持不稳定;删旧建新能保证白名单与 devices.txt 完全一致,代价只是 UUID 变化,而下游按名字引用 |
| 50 台设备怎么办? | 批量通过合并成一个任务:一次 clone、一次重建循环、一次 commit,耗时近似线性于 Profile 数,而非设备数 |