从「手点 80 下」到「点一次通过」

Apple 设备注册与描述文件自动化

Posted by PaysonChen on October 23, 2026
项 内容
分享主题 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 目标(当时写进方案的验收线)

  1. 一条命令 / 一次点击完成 登记 → 重建 → 归档;
  2. 重复执行不产生重复设备、不产生多余提交(幂等);
  3. 多 Apple 账号 零代码切换;
  4. Profile 名字稳定(UUID 可变,名字不可变);
  5. 失败能定位、能重试,而不是”再跑一次试试”。

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。

两个高危前提:

  1. .env.<team> 必须填全部团队相关变量,漏一项就沿用基础 .env → Issuer 与 Key 串号 → 401;
  2. .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:17 Team1 完成、切 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 演进顺序(按收益)

  1. 双数组抽成单份 profiles.map 映射文件 → 消除脱节;
  2. 自助层接 CI / 企业微信机器人 → 结果主动播报;
  3. 上限阈值告警(≥90 台);
  4. devices.txt 按 --env 拆分 → 多 Team 设备隔离。

8. 三条可复用的经验

  1. 幂等 > 聪明。force 删旧建新、mv 归档、porcelain 守门、批内查重 —— 每个动作都设计成「重复执行结果一致」,比任何”增量更新”的巧妙逻辑都可靠。
  2. 把契约显式化。名字、目录、文件格式、访问入口,全部写死并文档化:provisioning_name ↔ ExportOptions.plist、./profiles/ ↔ sign_sync.sh、127.0.0.1 ↔ 审核台。显式映射可评审,自动推断迟早出事。
  3. 人工步骤要变成状态机。有状态、有日志、能重试,才谈得上交接;否则自动化只是把”只有他会”换成了”只有脚本会”。

9. Q&A 预案

问题 回答要点
为什么不用 match? 证书分发已由 Sign 仓库承担,match 会引入第二套证书仓库与加密口令,职责重叠;sigh 只做「重建 + 下载」,更轻
为什么不让提单端直接执行? 提单是开放入口,执行是特权操作。审核台只监听 127.0.0.1 + 状态机闸门,把”能提”与”能跑”分开
为什么坚持删旧建新而不是增量追加? Apple API 对”向已有 Profile 追加设备”支持不稳定;删旧建新能保证白名单与 devices.txt 完全一致,代价只是 UUID 变化,而下游按名字引用
50 台设备怎么办? 批量通过合并成一个任务:一次 clone、一次重建循环、一次 commit,耗时近似线性于 Profile 数,而非设备数