工程实践

iOS 可复现归档实战:定位两次构建差异

iOS 可复现归档实战:定位两次构建差异

同一提交在开发者电脑上归档成功,换到云端 Mac 或夜间流水线后也显示成功,但两份产物的哈希不同。先不要把问题归结为 Xcode 缓存。签名、压缩时间、生成源码、脚本输出顺序和绝对路径都可能进入产物。更有效的做法,是在同一台 ArmVMS 节点上创建两套完全隔离的构建目录,连续执行两次无签名归档,再从外向内比较差异。

先定义“可重现”的检查边界

已签名 IPA 很难直接做到字节完全一致,因为签章数据、描述文件和封装元数据可能变化。工程检查应拆成两层:

  1. 内容层:相同提交、工具链、依赖锁文件和构建参数,生成相同的应用资源与可执行文件。
  2. 交付层:导出方式、签名配置、权限声明和包内文件符合发布要求,但不强求整份 IPA 哈希相同。

先证明无签名应用内容稳定,再检查签名和导出。直接比较两份 IPA 的总哈希,只能确认“不同”,无法说明差异来自哪里。

开始前记录提交哈希、Xcode 路径、SDK、Scheme、Configuration,以及 Package.resolvedPodfile.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_FLAGSOTHER_LDFLAGS、搜索路径、部署目标和实际工具路径。生成代码若写入当前日期、临时目录或无序字典遍历结果,也会使后续编译全部漂移。

特别留意 #filePath、调试映射和脚本输出中的工作区绝对路径。CI 工作目录变化时,可通过统一检出路径或合理设置调试前缀映射降低路径差异,但不要为了哈希一致而删除调试所需信息。

把检查变成可维护的门禁

门禁不应简单规定“两份 IPA 哈希必须相等”。更稳妥的策略是保存差异报告,并按层级判定:

必须阻断的变化

同一提交出现文件缺失、资源内容变化、权限声明变化、嵌入 Framework 版本变化,或主可执行文件在相同输入下不稳定,应立即阻断并保留两份归档。

可以单独审查的变化

签章目录、导出日志和明确标注的打包时间属于交付层差异,可进入独立检查,但必须限定允许变化的路径,不能用“忽略所有 plist”这类宽泛规则。

修复后应重新创建两套空目录复验,而不是复用旧 DerivedData。大型工程无需每个提交都执行双次归档,可以在 Xcode 版本、依赖锁文件、生成器、Run Script 或发布配置变化时触发。这样得到的不是一条脆弱哈希规则,而是一套能够指出差异发生在哪一层的构建证据链。

常见问题

为什么不直接比较两份已签名 IPA 的哈希?

签名数据、描述文件和打包时间可能产生预期差异。应先比较无签名归档中的应用内容,再单独验证签名与导出阶段。

Mach-O UUID 不一致是否一定代表代码发生变化?

不一定。它说明链接结果不同,需要继续比较编译参数、对象文件顺序、生成源码、链接输入和工具链路径。

可复现检查应该在每次提交上运行吗?

小型工程可以定期全量执行;大型工程更适合在工具链、依赖锁文件、构建脚本或发布配置变更时运行。

ArmVMS 云端 Mac

为下一次构建选择独享 Apple Silicon 物理节点

按天、周、月或季租用 ArmVMS M4 与 ArmVMS M4 Pro。配置、节点和附加项目在创建订单前逐项确认。

选择配置并创建订单