ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS上架工具链全解析:从签名打包到上传审核排错

iOS上架工具链全解析:从签名打包到上传审核排错

做 iOS 上架这件事,第一次点下“提交审核”之前,多数人是懵的:证书在哪里生成,描述文件为什么会突然“Invalid”,Archive 打包出来的 ipa 到底走哪条路上传,审核被拒后错误码指向的又是哪一环。真正把 App Store 上架工具摸一遍之后你会发现,这件事的本质不是“会用 Xcode 点几下”,而是一条由签名、构建、元数据、传输、审核反馈五段串起来的工具链,每一段都有官方工具和第三方工具两条路可走,选错了就会出现“本地能跑、打包失败”“上传成功、审核卡住”这种典型撕裂。这篇内容面向所有正在或即将把 iOS 应用送上 App Store 的开发者,不管你是第一次独立上架,还是团队里负责维护发布流水线的那位,我都会把每一段的工具选择逻辑、参数含义、踩坑经验摊开讲清楚,让你少走几轮来回。

1. 一条上架链路上的六个工具位,先搞清楚谁管什么

1.1 把“上架”拆成五段,工具才不会被乱用

很多人把上架理解成一个动作,其实它是五个动作的组合,每一段失败的表现形式完全不同。第一段是签名,决定你的包能不能被系统信任;第二段是构建,决定产物是 ipa 还是 xcarchive;第三段是元数据,包括截图、描述、关键词、隐私声明;第四段是传输,把包和元数据送到服务端;第五段是审核反馈,包括构建处理状态和审核结果。这五段之间不是严格的线性关系,构建可能做了三次才成功,元数据可能在审核阶段被打回重改,所以工具链的设计原则是:每一段都能独立重跑,且重跑不污染前一段的产物。

我见过不少团队的流程是这样的:在 Xcode 里点 Archive,然后 Organizer 里点 Distribute,弹出窗口里选证书,导出一个 ipa,再用某个图形化工具拖进去上传。这条链路能跑通,但它有三个问题:一是 Archive 产物没有归档命名规则,出问题时不知道要复现哪一个;二是证书选择依赖本机钥匙串状态,换一台机器就崩;三是元数据全靠网页后台手填,版本一多就开始记不清哪张截图对应哪个语言。这三个问题的根因都不是“工具不好用”,而是没有把每一段的输入输出显式化。

所以我建议的做法是,先用一张表把五段对应的工具位定下来,再决定哪一段手工、哪一段自动化。下面这张表是我自己在项目里常用的分工方式,你可以直接照抄再微调:

阶段官方工具第三方/自动化工具是否建议自动化
签名与证书Xcode 自动签名、钥匙串fastlane match、sigh强烈建议
构建打包xcodebuild、Archivefastlane gym建议
元数据与截图App Store Connect 后台fastlane deliver、snapshot、frameit视版本节奏
传输上传Transporter、iTMSTransporterfastlane deliver/pilot、App Store Connect API强烈建议
审核反馈App Store Connect、邮件无成熟替代手工为主

这张表最关键的一列不是“工具名”,而是最后一列。签名和传输是最应该自动化的两段,因为它们的失败率最高、手工成本最大、且结果完全确定;元数据自动化要看你的发版频率,一个季度发一次的应用,手填几十分钟完全可以接受,一周发三次的就必须上脚本;审核反馈这一段目前没有靠谱的自动化替代方案,因为审核结论里包含大量需要人判断的语义信息。

1.2 官方工具和第三方工具的边界在哪里

一个很实际的问题:既然 fastlane 能一把梭,为什么还要懂 xcodebuild?答案是故障定位。fastlane 本质上是官方命令行的封装,它把 xcodebuild 的调用参数、输出日志、退出码都包了一层。当 gym 报一个Error Domain=IDEProvisioningErrorDomain的时候,如果你只会看 fastlane 的日志,你就只能去搜关键词;但如果你知道它在底下调的是哪条 xcodebuild 命令,你可以把那条命令单独拎出来跑一遍,加上-verbose,错误信息会清晰十倍。

同理,上传环节的iTMSTransporter也是一个容易被忽略的官方工具,它是 Transporter 图形界面背后的实际执行者。很多上传卡在“正在验证包”这一步半小时不动的情况,直接用 iTMSTransporter 的命令行模式跑,日志会一行一行打出来,能立刻看出是网络问题还是包本身的问题。所以我的原则是:自动化用封装工具,排错回原始命令行。这两套东西必须都会,缺一套就会在关键时刻卡死。

还有一个边界问题是签名。Xcode 的自动签名在个人开发者账号下非常好用,它会自动帮你创建证书、App ID、描述文件,甚至自动处理推送和关联域这类 capability。但它在两种情况下会失效:一是团队多人协作,证书被覆盖;二是账号权限受限,比如你只有 App Manager 角色但没有 Admin 角色,自动签名会卡在创建描述文件这一步。这两种情况都必须切到手动或 match,后面第 2 章会细讲。

2. 签名与描述文件:自动签名、手动配置和 match 各自适合谁

2.1 自动签名到底“自动”了什么

打开 Xcode 的 Signing & Capabilities 面板,勾上 Automatically manage signing,选一个 Team,剩下的它全包了。这个过程里它实际做了四件事:在你的开发者账号下查找或创建一张分发证书;创建或更新一个 App ID,并把你在 Capabilities 里勾选的推送、关联域、应用内购买等能力写进去;生成一份 App Store 分发类型的描述文件,把证书和 App ID 绑在一起;把描述文件下载到本机的~/Library/MobileDevice/Provisioning Profiles/目录。

理解了这四件事,很多“莫名其妙”的问题就有解释了。比如你换了台机器,自动签名重新生成了一张证书,开发者账号下的分发证书数量是有上限的,达到上限后它会把最旧的那张吊销,于是原来那台机器上的包就再也无法用旧证书重签。再比如你加了一个新的 capability,自动签名会去更新描述文件,但如果这个 capability 需要账号管理员在后台额外开通(比如某些需要申请的能力),自动签名就会报错。所以自动签名适合的场景非常明确:单人开发、账号权限完整、不需要在多个环境重复构建同一个版本。

2.2 手动配置的三件套:证书、App ID、描述文件

手动配置的核心是把三个文件的关系理清楚,这三者的关系是:描述文件 = 一张分发证书 + 一个 App ID + 一组设备(分发类型下设备列表为空)。任何一处对不上,签名就会失败。这三件套对应的常见报错可以对照下面这张表:

报错关键词实际含义处理方向
No signing certificate found本机钥匙串里没有与描述文件匹配的私钥导入含私钥的 p12,而不是只有 cer
Provisioning profile doesn't include signing certificate描述文件绑定的证书与你当前选的证书不是同一张重新生成描述文件或换证书
doesn't match any valid, non-expired certificate/private key pair证书过期,或私钥缺失检查证书有效期与钥匙串配对状态
Bundle identifier is not availableApp ID 已被占用或拼写不符核对 Bundle ID 大小写与连字符

这里有一个特别容易被忽略的细节:导出的 .cer 文件只有公钥,没有私钥。证书本身不包含私钥,私钥在你生成证书请求的那台机器的钥匙串里。所以团队协作时导出给别人的必须是 .p12(含私钥)并设密码,而不是 .cer。我见过不止一次有人把 .cer 发给同事,同事导入后 Xcode 显示证书存在,但一签名就报“no private key”,来回折腾半天。

2.3 match 把证书变成可以版本管理的资产

手动配置的下一个问题是:三件套怎么在团队和 CI 之间同步?最原始的办法是压缩包传来传去,密码写在文档里,一旦有人离职或者密码改了,全线崩。fastlane 的 match 解决的正是这个问题,它的思路很直接:把证书和描述文件加密后存到一个私有的 Git 仓库里,所有人包括 CI 都从这个仓库拉取。

初始化只需要一条命令:

fastlane match init

它会问你仓库地址,然后生成一个Matchfile。之后执行:

# 首次生成或更新全部证书与描述文件 fastlane match appstore # 只读模式,CI 上必须用这个,防止 CI 误创建新证书 fastlane match appstore --readonly # 指定某个 bundle id fastlane match appstore -a com.example.app

--readonly这个参数是重中之重。CI 环境的账号权限通常是受限的,如果不加只读模式,某次构建发现本地没有证书,match 就会尝试去创建一个新的,结果就是又吊销了一张旧证书,其他环境全线挂掉。我在一个项目里就因为这个参数漏加,导致测试同事的包全部失效,重新配置花了整整一个下午。

match 还有一个隐含价值:它会把你的签名配置变成文本可审计的。仓库里的文件是加密的,但Matchfile和调用脚本是可以走代码评审的,谁改了签名方式、加了哪个 capability,在合并请求里一目了然。这一点比“某个人在本机 Xcode 里点了几下”要可靠得多。

注意:match 仓库的加密密码不要和仓库地址放在同一个地方。密码进 CI 的密钥管理,仓库地址可以写在公开配置里。

3. 打包环节:xcodebuild 与 gym 的参数账

3.1 Archive 和 Export 是两个动作,不是一个

Xcode 图形界面里 Archive 之后点 Distribute,看起来是一个连续动作,实际背后是两条命令:xcodebuild archive生成.xcarchive,xcodebuild -exportArchive从 xcarchive 里导出 ipa。拆开这两步的价值在于:xcarchive 是可以复用的。你打一次包,可以导出一份 App Store 的 ipa,同时导出一份 Ad Hoc 的 ipa 给测试,不用重新编译。对于编译要十几分钟的中大型项目,这个差别非常明显。

标准的 Archive 命令大致是这样:

xcodebuild archive \ -workspace MyApp.xcworkspace \ -scheme MyApp \ -configuration Release \ -archivePath ./build/MyApp.xcarchive \ -destination "generic/platform=iOS" \ | xcpretty

这里有几个参数值得单独说。-workspace用于有 CocoaPods 或需要多 target 联编的项目,纯 Swift Package 项目可以用-project。-destination "generic/platform=iOS"表示不针对模拟器也不针对某台真机,只针对 iOS 通用设备,这是打包必须的,如果误写成模拟器 destination,Archive 会直接失败。-configuration Release不用多说,但要注意如果你的工程里 Release 配置的优化等级或编译条件有特殊设置,打包前一定要确认,因为 Debug 和 Release 在真机上表现不一致的 bug 太常见了。

导出的命令是这样:

xcodebuild -exportArchive \ -archivePath ./build/MyApp.xcarchive \ -exportOptionsPlist ./ExportOptions.plist \ -exportPath ./build/output \ -allowProvisioningUpdates

-allowProvisioningUpdates这个参数在新版 Xcode 里几乎是必需的,否则在 CI 上导出时会因为无法访问开发者账号而失败。它的作用是允许命令行工具去更新描述文件,配合 match 的只读模式使用时要小心,两者结合的效果是“能拉取但不新建”,这个组合是我目前认为最稳的。

3.2 ExportOptions.plist 里真正影响结果的字段

ExportOptions.plist是导出环节的控制中心,很多人是随手复制一份别人的文件然后用,但里面几个字段如果写错,会直接导致上传阶段被拒或者证书不匹配。下面这几个字段必须自己核对:

<key>method</key> <string>app-store-connect</string> <key>teamID</key> <string>ABCDE12345</string> <key>signingStyle</key> <string>manual</string> <key>provisioningProfiles</key> <dict> <key>com.example.app</key> <string>MyApp AppStore Profile</string> </dict> <key>uploadSymbols</key> <true/> <key>stripSwiftSymbols</key> <true/>

method字段在不同版本的工具链里取值命名有过调整,早期是app-store,后来区分出更细的上传用途,具体取值建议以你当前 Xcode 版本导出的模板为准,最保险的方式是先在图形界面里人为导出一次,把生成的 plist 留下来当模板,再改成脚本用。teamID必须和证书所属团队一致,团队账号和公司账号混用的情况特别容易在这里出错。signingStyle设为manual时,provisioningProfiles就必须逐个 bundle id 写清楚,多 target 项目(比如主 App 加几个 Extension)要全部列出来,少一个就会导出失败。uploadSymbols打开是为了把 dSYM 一起传上去,后面崩溃日志符号化要靠它,如果关掉,线上崩溃堆栈就全是十六进制地址。

3.3 gym 的封装与反封装:什么时候该放弃 gym

gym 是 xcodebuild 的封装,它的好处是把 Archive 和 Export 合并成一个 action,参数用 Ruby 的键值对写,可读性好很多:

gym( workspace: "MyApp.xcworkspace", scheme: "MyApp", configuration: "Release", export_method: "app-store-connect", output_directory: "./build", output_name: "MyApp.ipa", include_symbols: true, clean: true )

这段配置和前面两条 xcodebuild 命令做的事情完全一样。gym 的优点是它能和 match、deliver 共享同一份Appfile,bundle id 和 team id 不用重复写;缺点是它把中间产物藏起来了,.xcarchive在哪里、导出日志在哪、失败时是哪一条命令报的错,都需要翻 fastlane 的 log 目录。所以我的实际用法是:日常发版用 gym,一旦出问题就关掉 gym,用 xcodebuild 原生命令重跑一遍。

这里有一条经验值得记下来:gym 报错时,log 里会有一行Exit status of command 'xcodebuild ...' was 65,把这行后面的完整命令复制出来,去掉输出重定向,直接在你本机终端跑。这一步能把“封装层丢失的信息”全部还原,绝大多数签名类、Provisioning 类的错误,用原生命令跑一次就能看到 Xcode 自己给出的建议修复项。这个习惯帮我节省了大量在论坛里翻帖子的时间。

4. 截图与元数据:deliver、snapshot 能省下的重复劳动

4.1 元数据不是一张表,是一棵目录树

App Store Connect 后台的元数据看起来是一堆输入框,但 fastlane 把它们映射成了一个固定结构的目录树,fastlane deliver init会在项目里生成fastlane/metadata/,结构大致是这样:

metadata/ en-US/ description.txt keywords.txt promotional_text.txt release_notes.txt support_url.txt marketing_url.txt privacy_url.txt name.txt subtitle.txt zh-Hans/ ... review_information/ first_name.txt email_address.txt notes.txt

这个结构的价值不在于“少点几次网页”,而在于它进了版本库。每一次文案改动都有 diff,谁改了关键词、哪个版本的更新说明写了什么,全都可以追溯。审核被拒时最常见的场景是“审核员认为描述与实际功能不符”,这时候翻出上一个版本的description.txt对比一下,就知道是这次改文案引入的问题,还是原来就有的表述。

review_information这个目录特别值得说,它是给审核团队看的备注。很多人不填,觉得浪费时间,但实操中这一块填得好,能省掉一次完整的往返。演示账号、是否需要登录、哪些功能依赖特定网络环境、哪些页面的入口在哪,这些写清楚,审核员就不用发消息问你。我在一个需要登录才能看到核心功能的应用上,因为备注里写清了测试账号和推荐的操作路径,审核一次通过;同一团队的另一个应用没写,被问了一轮“如何访问完整功能”,来回三天。

4.2 截图自动化的两个瓶颈:设备矩阵与本地化

snapshot 的作用是自动在多个模拟器上跑 UI 测试、在每个关键页面截图、自动加上状态栏的美化。它的原理其实很朴素:启动模拟器、跑一个 UI Test target、在指定位置调用snapshot()截图,然后把图按语言和设备尺寸归档。

snapshot( devices: ["iPhone 16 Pro Max", "iPhone 16", "iPad Pro 13-inch (M4)"], languages: ["en-US", "zh-Hans"], output_directory: "./screenshots", clear_previous_screenshots: true, reinstall_app: true, erase_simulator: true )

这里第一个瓶颈是设备矩阵。App Store 对截图尺寸有明确要求,而你不需要为每一种尺寸都真跑一遍,通常选三到四档覆盖主流尺寸即可,剩下的交给系统缩放。跑太多设备只会让流水线时间翻倍,收益很低。第二个瓶颈是本地化。语言数量乘以设备数量乘以页面数量,是截图任务的实际数量。四个语言、三台设备、五个页面就是六十张图,跑一次可能二十分钟。我的做法是:把截图任务从每次提交里拆出来,只在 UI 有明显变化或新增语言时跑,其他版本复用上一次的截图。

还有一个隐藏坑点:截图里的内容必须是真实可展示的。如果 UI 测试用的是假数据填充,截出来的图里出现了“[Mock]”“test123”这类字样,审核是会被拒的。所以 UI 测试的数据源要么走一个专门的演示环境,要么在截图前用固定的种子数据。我见过因为截图里露出内部测试水印被打回的案例,非常不值。

4.3 隐私声明与审核备注的版本化管理

近两年审核对隐私的要求越来越细,PrivacyInfo.xcprivacy这类隐私清单文件已经成了必填项,第三方 SDK 也需要各自带上自己的清单并完成签名。这块内容放在元数据章节讲,是因为它实际上有“代码侧”和“申报侧”两份:代码侧是打包进 ipa 的清单文件,申报侧是 App Store Connect 里填写的隐私问卷。两份必须一致,不一致是常见的拒审原因之一。

我的做法是在项目里维护一份privacy-checklist.md,列出每个第三方 SDK 的名称、版本、收集的数据类型、是否用于追踪、对应的清单文件路径。每次升级 SDK 都要过一遍这张表,同时更新后台问卷。这份表看起来是额外工作,但当审核问起“你们是否使用了某类数据用于广告归因”时,能在五分钟内给出准确答案,比临时翻代码强太多。

5. 传输与上传:Transporter、iTMSTransporter 与 API 通道

5.1 三条上传通道的差异和使用场景

把 ipa 送到服务端这件事,目前有三条路:图形化的 Transporter 应用、命令行工具 iTMSTransporter、以及基于 App Store Connect API 的上传方式(fastlane 的 deliver/pilot 底层走的就是 API)。三者的差异不在功能,而在可观测性和可自动化程度。

Transporter 是图形界面,拖进去点“交付”,进度条走完就结束。它最大的优点是日志相对友好,失败时会给出明确的错误描述;最大的缺点是完全无法自动化,而且它只能上传已经导出的 ipa,不能上传元数据。iTMSTransporter 是它的命令行版本,用xcrun iTMSTransporter调用,可以写进脚本,日志输出比图形界面更细。App Store Connect API 这条路是最现代的,用密钥对认证,不依赖账号密码和双重验证,非常适合 CI。

通道认证方式可自动化适合场景
Transporter 图形界面账号登录否临时手动补传、排查问题
iTMSTransporter账号或 API Key是脚本化上传,日志详细
App Store Connect APIAPI Key(p8)是CI 流水线首选

这里要提醒一句:早期那套基于账号密码的上传命令已经逐步退出历史舞台,如果还在脚本里用老式写法,很可能某天突然失效。迁移到 API Key 是迟早的事,早迁早省心。

5.2 App Store Connect API Key 的权限设计

API Key 在后台生成,会给你三样东西:Key ID、Issuer ID、以及一个只能下载一次的.p8私钥文件。这三样东西的组合就是认证凭据。fastlane 里配置大致是这样:

app_store_connect_api_key( key_id: ENV["ASC_KEY_ID"], issuer_id: ENV["ASC_ISSUER_ID"], key_filepath: ENV["ASC_KEY_PATH"], duration: 1200, in_house: false )

权限设计上有一个原则:按用途分开建 Key。上传构建、管理 TestFlight 测试员、读取销售报表,这三类操作的权限范围不一样,如果共用一个 Key,一旦泄露影响面就大。后台可以给每个 Key 指定角色,把上传用的 Key 限制在最小的必要权限上。另外.p8文件下载后无法再次获取,一定要放进密钥管理系统,不要提交到代码仓库,也不要以明文形式出现在任何日志里。

duration参数是令牌有效期,单位是秒,默认值通常够用。如果你的流水线里上传大包耗时较长,可以适当调大,但不要设得过长,令牌失效时的重试逻辑也要有,否则一个长任务跑到一半令牌过期,会得到一个很隐晦的认证错误。

5.3 上传失败的常见报错读法

上传阶段的报错往往以ITMS-开头,配一段英文描述。这些错误的排查有一个通用套路:先判断是包本身的问题还是服务端状态的问题。判断方法很简单,同一个包隔几小时重传一次,如果还是同样报错,就是包的问题;如果变成成功,就是服务端处理队列的问题。

包本身的问题里,最常见的是版本号冲突和签名不匹配。版本号分两个字段,CFBundleShortVersionString是对外的版本号,比如 2.3.0;CFBundleVersion是构建号,同一版本下每次上传必须递增。很多人只改前者不改后者,就会收到“已存在相同版本”的报错。构建号的递增规则建议直接用流水线序号或者时间戳,不要手工维护,手工维护必出错。

签名不匹配的表现是上传成功但处理阶段失败,提示找不到匹配的签名证书或者描述文件类型不对。这种情况要回头检查导出的 ipa 到底用的是哪种签名方式,有时候本地 Xcode 缓存的描述文件和实际使用的不一致,最保险的做法是用codesign -dv --verbose=4 MyApp.app直接看包里的签名信息,比猜要快。

6. 拒审与构建失败:一套可复现的排查链路

6.1 先分清“构建处理失败”和“审核被拒”

这两个是完全不同的东西,但经常被混为一谈。构建处理失败发生在包上传之后、进入审核队列之前,App Store Connect 的构建列表里会显示一个感叹号,点进去有具体的 ITMS 错误码。审核被拒发生在人工审核之后,会给你一份 Resolution Center 的说明,里面引用具体的审核条款编号。前者是技术问题,后者可能是技术问题,也可能是内容、元数据、隐私申报的问题。

我把排查顺序固定成四步,每次都按这个走:

  1. 看构建状态。构建列表里如果这个版本还没变成“可供提交”,说明卡在处理阶段,先解决技术问题,别急着提交。
  2. 读错误码原文。ITMS 错误码的描述往往是精确的,比如“缺少某个用途描述字符串”,说明你的 Info.plist 里少了一个 key。
  3. 本地复现。用xcodebuild原生命令把整个打包流程跑一次,很多时候本地就能看到警告。
  4. 对照条款。如果是审核拒绝,先找到条款编号,再看 Resolution Center 里审核员写的具体句子,别凭感觉改。

这套顺序的价值在于避免“改一堆东西然后重新提交”的盲目操作。盲目改的后果是,即使通过了,你也不知道是哪一处改动起了作用,下次遇到同类问题还是要重来一遍。

6.2 一张按现象索引的排查表

下面这张表是我这几年积累下来的,按“你看到的现象”索引,而不是按错误码索引,因为很多人在拿到错误码之前只知道现象:

现象最可能的原因首选排查动作
上传后一直“正在处理”服务端队列积压,或包内含超大资源等两小时再传一次,检查资源体积
构建显示“无效二进制”签名类型不匹配、缺少必需架构codesign -dv看签名,lipo -info看架构
审核拒绝引用隐私条款隐私问卷与实际采集行为不符对照三方 SDK 清单逐项核对
审核拒绝称功能不完整演示账号失效或关键入口隐藏重置演示账号,补全审核备注
审核拒绝称内容重复应用与已有应用同质化突出差异化功能,调整描述与截图
TestFlight 安装失败设备未加入测试组或构建未通过处理检查测试组分配与构建状态

表格里“功能不完整”这一条特别值得展开。很多应用的核心功能需要登录才能看到,而审核员拿到的演示账号如果过期、或者权限不足,看到的就是一个空壳。审核员不会替你去猜,直接按“功能不完整”拒。解决方式是在审核备注里写清楚完整路径,并保证演示账号在整个审核期间有效。我现在的做法是给审核专门准备一个长期有效的演示账号,密码写在备注里,且这个账号的数据是固定种子,不随真实用户数据变化。

6.3 隐私清单和第三方 SDK 签名这两年的新门槛

这两项是近两年新增的硬性要求,也是“以前能过、现在过不了”的主要来源。隐私清单要求应用和第三方 SDK 各自声明数据收集类型、是否用于追踪、以及使用某些系统 API 的理由。第三方 SDK 还需要提供带签名的二进制,未签名的 SDK 会导致整个包在处理阶段失败。

排查这类问题的方法是从包的结构入手,把 ipa 解开看Frameworks目录里有哪些 framework,逐个确认是否带签名、是否带隐私清单:

# 查看已签名信息 codesign -dvvv MyApp.app/Frameworks/SomeSDK.framework # 列出包内框架 ls MyApp.app/Frameworks/

如果某个 SDK 既没有签名也没有清单,唯一的办法是升级到该 SDK 提供了合规版本的新版本号,或者换掉它。这一步没有捷径,因为它是二进制层面的要求,改配置绕不过去。我的建议是把“三方 SDK 合规检查”加进发版前的检查清单,每次升级依赖都过一遍,别等到上传之后才发现。

7. 把上架流程放进流水线:从手动点到一键出包

7.1 流水线拆解与密钥管理

把上架自动化,本质上就是把前面五段串成一条命令能跑完的链路。一个典型的分段是:拉代码、装依赖、跑测试、match 拉取签名、gym 打包、deliver 上传、上传 dSYM。每一段都应该能单独触发,用于定位问题。

steps: - run: bundle install - run: bundle exec fastlane match appstore --readonly - run: bundle exec fastlane build - run: bundle exec fastlane upload

密钥管理是这条链路里最容易被轻视的环节。需要放进密钥系统的东西至少包括:match 仓库地址与解密密码、App Store Connect API Key 的三件套、以及可能的通知 webhook 凭据。原则是流水线里不出现任何明文密钥,全部通过环境变量注入,且这些变量的读取权限限制在需要的步骤上。另外,构建日志要过滤敏感输出,fastlane 的某些步骤在调试模式下会把环境变量打出来,这一点要提前关掉。

7.2 构建号的唯一性与自增策略

构建号冲突是自动化流水线最常见的失败原因。手工维护的构建号在多分支并行时必然撞车。我的建议是用一个单调递增的来源,比如流水线的全局计数器,或者基于时间生成。fastlane 提供了现成的 action:

increment_build_number( build_number: latest_testflight_build_number + 1 )

这个写法会去查询 TestFlight 上最新的构建号再加一,好处是绝对不会撞车,坏处是它依赖网络请求,如果查询失败整个流程就断。所以更稳的做法是本地维护一个计数文件,或者直接用 CI 的系统变量。我在项目里用的是“主版本号来自代码,构建号来自 CI 序列号”的混合方案,代码里的CFBundleShortVersionString由人在合并请求里改,CFBundleVersion完全由流水线写入,两边职责清晰,不会互相干扰。

7.3 TestFlight 的分发策略与灰度节奏

上传成功不等于可以让用户用了,中间还隔着 TestFlight 这一层。TestFlight 的价值在于它是审核之前的一次真实环境验证。内部测试组不需要审核就能装,外部测试组需要一次简化的审核,但比正式上架快很多。我的常规节奏是:构建上传后先进内部组,自己装一遍走核心路径,确认没有问题再提交外部测试,最后才是提交正式审核。

这个节奏的意义在于把问题挡在最便宜的地方。内部测试发现崩溃,你只需要重新传一个包;外部测试发现崩溃,你需要处理测试员的反馈;正式审核被拒,你要重新排队。三者的成本差得很远。另外 TestFlight 上的构建是有有效期的,测试组的成员也要维护,如果长期不用,记得清理,避免堆积一堆无效测试员影响后续分发。

7.4 我在这条链路上踩过的几个坑

第一个坑是描述文件的缓存。本机 Xcode 缓存了旧的描述文件,脚本跑的时候用的却是新的,两边不一致导致导出的包签名异常。解决办法是在打包前主动清理~/Library/MobileDevice/Provisioning Profiles/下的旧文件,让 match 重新拉一遍。这个动作在 CI 上是必须的,因为 CI 的缓存有时候比你想的更顽固。

第二个坑是多 target 的签名配置。主 App 加几个 Extension 的项目,ExportOptions.plist里少写一个 bundle id,导出就会失败,而且错误提示不会明确告诉你是哪一个缺失。我的做法是把所有 bundle id 写进一个配置文件,脚本里遍历生成 plist,从源头上避免漏写。

第三个坑是元数据上传覆盖了人工修改。deliver 默认会覆盖后台的所有字段,如果某次有人在网页上紧急改了一句描述,而流水线又跑了一次,这句改动就没了。解决办法是明确规则:所有元数据改动必须走代码仓库,网页上的修改只作为临时应急且要立刻回写到仓库。这条规则听起来是流程问题,但它是自动化能否长期维持的关键。

第四个坑是截图的语言目录缺失。deliver 上传时如果某个语言目录下没有截图,它不会报错,而是保留后台原有截图,于是出现了“描述是新的、截图是旧的”这种奇怪状态。解决办法是在上传前做一次目录校验,缺哪个语言的截图直接让流水线失败,逼着补齐。

最后分享一个小习惯:每次发版之后,把当次的构建号、提交哈希、上传时间、审核结果记进一个简单的表格里。看起来是多余的动作,但当你在三个月后需要回溯“哪个版本引入了某个线上问题”时,这张表能让你在几分钟内定位到具体的包和对应的代码状态,比在聊天记录里翻要快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进