云端 Mac 上的 iOS Mach-O 架构与最低系统版本门禁

云端 Mac 上的 iOS Mach-O 架构与最低系统版本门禁

一份在本地模拟器运行正常的 iOS 工程,换到 HopVM 云端 Mac 完成 Archive 后,仍可能在安装阶段才暴露问题:某个预编译 Framework 混入了 x86_64 切片,扩展链接到了错误平台,或者嵌套动态库把最低系统版本悄悄抬高。与其等交付后排查,不如直接扫描导出的 .app,把二进制事实变成流水线门禁。

为什么要检查最终导出包

Xcode 工程设置只能说明“准备怎样构建”,最终包才说明“实际交付了什么”。主程序、App Extension、Framework 和 .dylib 都可能拥有独立的 Mach-O 头;依赖管理器下载的二进制包也未必继承主工程设置。

门禁至少回答三个问题:

  1. 面向真实 iOS 设备的二进制是否仅包含允许的架构。
  2. 每个 Mach-O 的平台是否为 iOS,而不是模拟器或 macOS。
  3. 嵌套二进制的最低系统版本是否高于主 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.plistMinimumOSVersion 与仓库基线不一致
主程序 LC_BUILD_VERSIONminos 高于产品声明
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 的失败后附件机制保留。

常见误报主要有三类:

因此脚本应按产品类型维护策略,不要让一套规则同时覆盖 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 配置中选择合适资源,按天、周、月或季租用。设备为独享物理机、非虚拟机,实际可用状态以控制台实时返回为准。

选择配置并下单