一份在本地模拟器运行正常的 iOS 工程,换到 HopVM 云端 Mac 完成 Archive 后,仍可能在安装阶段才暴露问题:某个预编译 Framework 混入了 x86_64 切片,扩展链接到了错误平台,或者嵌套动态库把最低系统版本悄悄抬高。与其等交付后排查,不如直接扫描导出的 .app,把二进制事实变成流水线门禁。
为什么要检查最终导出包
Xcode 工程设置只能说明“准备怎样构建”,最终包才说明“实际交付了什么”。主程序、App Extension、Framework 和 .dylib 都可能拥有独立的 Mach-O 头;依赖管理器下载的二进制包也未必继承主工程设置。
门禁至少回答三个问题:
- 面向真实 iOS 设备的二进制是否仅包含允许的架构。
- 每个 Mach-O 的平台是否为 iOS,而不是模拟器或 macOS。
- 嵌套二进制的最低系统版本是否高于主 App 的声明。
不要把“Archive 成功”当成交付验收。链接器只验证当前目标能否组成产物,不会替团队判断所有嵌套文件是否符合发布基线。
应检查通过 xcodebuild -exportArchive 生成的最终包,而不是只扫描 DerivedData。导出过程会重组 Framework、扩展和签名内容,这正是最接近交付状态的输入。
固定输入与验收基线
先把归档、导出和报告目录固定下来,避免脚本扫描到上一次任务的残留文件。云端 Mac 被多个流水线顺序使用时,工作目录隔离尤其重要。
set -euo pipefail
ARCHIVE_PATH="$PWD/output/App.xcarchive"
EXPORT_PATH="$PWD/output/export"
REPORT_PATH="$PWD/output/macho-report.tsv"
rm -rf "$EXPORT_PATH"
mkdir -p "$EXPORT_PATH"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$PWD/ci/ExportOptions.plist"
APP_PATH="$(find "$EXPORT_PATH" -maxdepth 2 -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"
产品基线应进入仓库,而不是散落在 CI 配置界面。例如用 ci/macho-policy.env 保存允许架构与最低版本:
EXPECTED_ARCHS="arm64"
EXPECTED_PLATFORM="IOS"
DECLARED_MIN_IOS="17.0"
DECLARED_MIN_IOS 必须与项目真实支持范围一致。这里的版本只是脚本示例,不能复制后直接当作产品决策。
递归枚举所有 Mach-O
不能只按扩展名查找,因为 Framework 主二进制通常没有扩展名。更稳妥的做法是遍历文件,再由 file 判断其是否属于 Mach-O。
: > "$REPORT_PATH"
failure=0
while IFS= read -r -d '' candidate; do
if ! file -b "$candidate" | grep -q 'Mach-O'; then
continue
fi
archs="$(lipo -archs "$candidate" 2>/dev/null || true)"
build="$(vtool -show-build "$candidate" 2>/dev/null || true)"
platform="$(awk '/platform/{print $2; exit}' <<<"$build")"
minos="$(awk '/minos/{print $2; exit}' <<<"$build")"
printf '%s %s %s %s
' \
"${candidate#"$APP_PATH"/}" "$archs" "$platform" "$minos" \
>> "$REPORT_PATH"
if [[ "$archs" != "$EXPECTED_ARCHS" ]]; then
printf 'architecture mismatch: %s (%s)
' "$candidate" "$archs" >&2
failure=1
fi
if [[ "$platform" != "$EXPECTED_PLATFORM" ]]; then
printf 'platform mismatch: %s (%s)
' "$candidate" "$platform" >&2
failure=1
fi
done < <(find "$APP_PATH" -type f -print0)
exit "$failure"
报告使用制表符分隔,既能作为构建附件保留,也方便后续转换成表格。失败信息必须包含文件路径和实际值;只输出“架构检查失败”会迫使排障者重新跑完整任务。
不要在门禁里就地修包
发现 x86_64 后直接执行 lipo -remove 看似省事,却会改变已签名内容,还会掩盖依赖生产流程的问题。正确路径是定位该文件来自源码编译、二进制依赖还是复制脚本,然后修正上游并重新 Archive。
比对最低系统版本
先读取主 App 的产品声明:
PLIST="$APP_PATH/Info.plist"
APP_MIN_IOS="$(/usr/libexec/PlistBuddy \
-c 'Print :MinimumOSVersion' "$PLIST")"
printf 'declared minimum iOS: %s
' "$APP_MIN_IOS"
再从每个 Mach-O 的 LC_BUILD_VERSION 读取 minos。判断时应采用语义化版本比较,不能用普通字符串比较,否则 17.10 可能被错误地排在 17.9 前面。
| 检查对象 | 读取位置 | 失败条件 |
|---|---|---|
| 主 App 声明 | Info.plist 的 MinimumOSVersion |
与仓库基线不一致 |
| 主程序 | LC_BUILD_VERSION 的 minos |
高于产品声明 |
| Framework 与动态库 | 各自的 LC_BUILD_VERSION |
高于产品声明 |
| App Extension | 扩展 Info.plist 与 Mach-O | 声明或实际值超出基线 |
可用系统自带的版本排序完成比较:
version_gt() {
[[ "$1" != "$2" ]] &&
[[ "$(printf '%s
%s
' "$1" "$2" | sort -V | tail -n 1)" == "$1" ]]
}
if version_gt "$minos" "$APP_MIN_IOS"; then
printf 'minimum iOS mismatch: %s requires %s
' \
"$candidate" "$minos" >&2
failure=1
fi
若 vtool 没有返回平台或版本,不应静默跳过。先用 otool -l 保存完整加载命令,再确认它是不是旧格式二进制、异常文件或脚本误判。
接入 CI 与处理常见误报
把检查放在 Archive 与导出之后、上传之前,并始终上传 macho-report.tsv。即使任务失败,报告也应通过 CI 的失败后附件机制保留。
常见误报主要有三类:
- 扫描到了符号文件或归档目录,而不是最终
.app。 - 平台名称在不同工具输出中大小写不同,却直接做了字符串等值判断。
- 把 macOS 辅助工具的策略套到 iOS App,导致合法的通用二进制被错误拦截。
因此脚本应按产品类型维护策略,不要让一套规则同时覆盖 iOS、模拟器测试包和 macOS 工具。若流水线同时生成多种产物,应为每个导出目录单独执行,并在报告首列写入产物类型。
最后再做一次签名结构验证:
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
它不能替代 Mach-O 检查,但能发现脚本误改二进制、嵌套签名失效等后续问题。完整顺序应是导出、扫描、版本比对、签名验证、保存报告,然后才允许交付。这样每次失败都能落到具体文件、具体字段和具体修复入口,而不是在安装失败后猜测原因。
常见问题
为什么只检查主 App 的架构不够?
第三方 Framework、扩展和动态库都有独立的 Mach-O 文件。任何一个嵌套二进制带入模拟器切片、错误平台或更高最低系统版本,都可能让安装或启动失败。
iOS 交付包中发现 x86_64 应如何处理?
先定位对应文件及其来源,不要直接用 lipo 删除后继续交付。应回到依赖构建或归档步骤,确保导出包只包含面向真实设备的平台与架构。
最低系统版本应该以哪个位置为准?
主 App 的 Info.plist 是产品声明,Mach-O 的 LC_BUILD_VERSION 是实际编译结果。门禁应同时读取两者,并要求所有嵌套二进制的 minos 不高于产品声明。
用一台按项目启停的云端 Mac 复现工作流
从三档 Apple Silicon 配置中选择合适资源,按天、周、月或季租用。设备为独享物理机、非虚拟机,实际可用状态以控制台实时返回为准。