同一提交在开发者电脑上归档成功,换到云端 Mac 或夜间流水线后也显示成功,但两份产物的哈希不同。先不要把问题归结为 Xcode 缓存。签名、压缩时间、生成源码、脚本输出顺序和绝对路径都可能进入产物。更有效的做法,是在同一台 ArmVMS 节点上创建两套完全隔离的构建目录,连续执行两次无签名归档,再从外向内比较差异。
先定义“可重现”的检查边界
已签名 IPA 很难直接做到字节完全一致,因为签章数据、描述文件和封装元数据可能变化。工程检查应拆成两层:
- 内容层:相同提交、工具链、依赖锁文件和构建参数,生成相同的应用资源与可执行文件。
- 交付层:导出方式、签名配置、权限声明和包内文件符合发布要求,但不强求整份 IPA 哈希相同。
先证明无签名应用内容稳定,再检查签名和导出。直接比较两份 IPA 的总哈希,只能确认“不同”,无法说明差异来自哪里。
开始前记录提交哈希、Xcode 路径、SDK、Scheme、Configuration,以及 Package.resolved、Podfile.lock 等锁文件。工作区必须干净,未提交的生成文件也要纳入检查。
用隔离目录执行两次归档
两次构建不能共用 DerivedData,否则第二次可能读取第一次留下的中间产物。下面的脚本固定语言环境和时区,为每次归档分配独立目录,并关闭代码签名:
#!/bin/zsh
set -euo pipefail
export LC_ALL=C
export LANG=C
export TZ=UTC
ROOT="$PWD"
SCHEME="ExampleApp"
for RUN in A B; do
rm -rf "$ROOT/.repro/$RUN"
mkdir -p "$ROOT/.repro/$RUN"
xcodebuild archive \
-workspace "$ROOT/ExampleApp.xcworkspace" \
-scheme "$SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-derivedDataPath "$ROOT/.repro/$RUN/DerivedData" \
-archivePath "$ROOT/.repro/$RUN/App.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
COMPILER_INDEX_STORE_ENABLE=NO
done
如果工程必须在脚本阶段读取环境变量,应显式传入稳定值,不要继承交互式 Shell 中的临时配置。两次归档还应使用同一个 DEVELOPER_DIR,避免默认 Xcode 路径被切换。
从文件清单缩小差异范围
不要一开始就对二进制执行十六进制比较。先确认两份 .app 是否包含相同路径:
APP_A=".repro/A/App.xcarchive/Products/Applications/ExampleApp.app"
APP_B=".repro/B/App.xcarchive/Products/Applications/ExampleApp.app"
find "$APP_A" -type f | sed "s#^$APP_A/##" | LC_ALL=C sort > /tmp/files-a.txt
find "$APP_B" -type f | sed "s#^$APP_B/##" | LC_ALL=C sort > /tmp/files-b.txt
diff -u /tmp/files-a.txt /tmp/files-b.txt
文件数量不同,通常说明 Run Script 阶段、资源生成器或复制规则存在不稳定输入。路径一致后,再逐文件计算哈希,并把已知会变化的签章目录排除:
(
cd "$APP_A"
find . -type f ! -path "./_CodeSignature/*" -print0 |
sort -z | xargs -0 shasum -a 256
) > /tmp/hash-a.txt
(
cd "$APP_B"
find . -type f ! -path "./_CodeSignature/*" -print0 |
sort -z | xargs -0 shasum -a 256
) > /tmp/hash-b.txt
diff -u /tmp/hash-a.txt /tmp/hash-b.txt
| 首个差异位置 | 优先检查 |
|---|---|
| Info.plist | 自动写入的日期、主机名、路径或流水线编号 |
| Assets.car | 资源输入、工具链版本与资源目录顺序 |
| 可执行文件 | 编译参数、生成源码、对象文件与链接顺序 |
| Framework | 依赖锁文件、二进制依赖版本与嵌入脚本 |
| 文本或 JSON | 未排序集合、随机标识和区域格式 |
深入检查配置与 Mach-O
属性列表可能只因键顺序不同而产生字节差异。复制文件后用 plutil -convert xml1 规范化,再执行文本比较。不要直接改归档原件,否则会污染后续证据。
可执行文件不同时,先查看 UUID:
dwarfdump --uuid "$APP_A/ExampleApp"
dwarfdump --uuid "$APP_B/ExampleApp"
xcodebuild \
-workspace ExampleApp.xcworkspace \
-scheme ExampleApp \
-configuration Release \
-showBuildSettings > /tmp/build-settings.txt
UUID 不同只表示链接结果不同,并不自动证明业务代码变化。继续核对 OTHER_SWIFT_FLAGS、OTHER_LDFLAGS、搜索路径、部署目标和实际工具路径。生成代码若写入当前日期、临时目录或无序字典遍历结果,也会使后续编译全部漂移。
特别留意 #filePath、调试映射和脚本输出中的工作区绝对路径。CI 工作目录变化时,可通过统一检出路径或合理设置调试前缀映射降低路径差异,但不要为了哈希一致而删除调试所需信息。
把检查变成可维护的门禁
门禁不应简单规定“两份 IPA 哈希必须相等”。更稳妥的策略是保存差异报告,并按层级判定:
必须阻断的变化
同一提交出现文件缺失、资源内容变化、权限声明变化、嵌入 Framework 版本变化,或主可执行文件在相同输入下不稳定,应立即阻断并保留两份归档。
可以单独审查的变化
签章目录、导出日志和明确标注的打包时间属于交付层差异,可进入独立检查,但必须限定允许变化的路径,不能用“忽略所有 plist”这类宽泛规则。
修复后应重新创建两套空目录复验,而不是复用旧 DerivedData。大型工程无需每个提交都执行双次归档,可以在 Xcode 版本、依赖锁文件、生成器、Run Script 或发布配置变化时触发。这样得到的不是一条脆弱哈希规则,而是一套能够指出差异发生在哪一层的构建证据链。
常见问题
为什么不直接比较两份已签名 IPA 的哈希?
签名数据、描述文件和打包时间可能产生预期差异。应先比较无签名归档中的应用内容,再单独验证签名与导出阶段。
Mach-O UUID 不一致是否一定代表代码发生变化?
不一定。它说明链接结果不同,需要继续比较编译参数、对象文件顺序、生成源码、链接输入和工具链路径。
可复现检查应该在每次提交上运行吗?
小型工程可以定期全量执行;大型工程更适合在工具链、依赖锁文件、构建脚本或发布配置变更时运行。
为下一次构建选择独享 Apple Silicon 物理节点
按天、周、月或季租用 ArmVMS M4 与 ArmVMS M4 Pro。配置、节点和附加项目在创建订单前逐项确认。