工程实践

云端 Mac CI 中验收 iOS 与 watchOS 配套包

云端 Mac CI 中验收 iOS 与 watchOS 配套包

一个同时包含 iOS App 与 watchOS App 的归档,在主应用编译成功后仍可能无法交付:手表端没有被嵌入、构建号落后一版、扩展指向旧 Bundle ID,或者某个内层组件没有正确签名。此类问题不应等到导出或提交阶段才发现。更稳妥的做法,是让云端 Mac 每次生成 xcarchive 后,直接检查最终包结构,并把结果作为流水线门禁。

为什么要检查最终归档

工程文件描述的是预期,xcarchive 才是实际交付物。不同配置文件、构建脚本和环境变量都可能覆盖版本号或产品标识符。只读取工程设置,无法证明 watchOS App 已经出现在主应用的 Watch 目录中,也无法证明扩展与外层应用保持正确关系。

建议把验收对象固定为 Release 归档,而不是 DerivedData 中间目录。先使用与正式流水线相同的工作区、Scheme 和配置生成产物:

set -euo pipefail

ROOT="$PWD"
OUT="$ROOT/out"
ARCHIVE="$OUT/Client.xcarchive"

rm -rf "$ARCHIVE"
mkdir -p "$OUT"

xcodebuild \
  -workspace Client.xcworkspace \
  -scheme Client \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath "$ARCHIVE" \
  clean archive

clean archive 适合基线验收,日常流水线可以按缓存策略调整,但验收脚本必须始终读取本次任务生成的归档,不能回退到某个“最近生成”的目录。

门禁应回答“这次准备交付的包里有什么”,而不是“项目理论上应该生成什么”。

从包结构建立组件清单

典型路径是 Products/Applications/*.app,其内部 Watch 目录包含 watchOS App,手表应用的 PlugIns 目录再包含扩展。不要把产品名硬编码进脚本;重命名 Target 后,固定路径很容易失效。

IOS_APP=$(find "$ARCHIVE/Products/Applications" \
  -maxdepth 1 -type d -name '*.app' -print -quit)

test -n "${IOS_APP:-}" || {
  printf '%s
' "iOS app not found"
  exit 1
}

WATCH_APP=$(find "$IOS_APP/Watch" \
  -maxdepth 1 -type d -name '*.app' -print -quit)

test -n "${WATCH_APP:-}" || {
  printf '%s
' "watchOS app not found"
  exit 1
}

WATCH_EXTENSION=$(find "$WATCH_APP/PlugIns" \
  -maxdepth 1 -type d -name '*.appex' -print -quit)

printf 'ios=%s
watch=%s
extension=%s
' \
  "$IOS_APP" "$WATCH_APP" "${WATCH_EXTENSION:-embedded}"

部分新项目没有独立的 .appex 层,因此扩展不存在不能直接判错。门禁应根据仓库声明的产品结构决定:旧式结构要求三层组件,较新的单目标结构只要求 iOS App 与 watchOS App。可以在仓库保存一份简短的预期清单,而不是根据目录猜测项目类型。

校验版本与标识符关系

先对每个 Info.plist 执行 plutil -lint,再读取 CFBundleIdentifierCFBundleShortVersionStringCFBundleVersion。同一次发布通常应让 iOS 与 watchOS 的营销版本、构建号完全一致。若团队采用独立构建号,也应把允许规则明确写入脚本。

检查项 推荐规则 失败风险
营销版本 iOS 与 watchOS 相同 商店版本关系不一致
构建号 同一发布任务相同 手表端被识别为旧构建
配套标识符 指向 iOS 主应用 ID 安装后无法建立配套关系
扩展归属 指向当前 watchOS App ID 扩展与外层应用错配
read_plist() {
  /usr/libexec/PlistBuddy -c "Print :$2" "$1"
}

IOS_PLIST="$IOS_APP/Info.plist"
WATCH_PLIST="$WATCH_APP/Info.plist"

plutil -lint "$IOS_PLIST" "$WATCH_PLIST"

IOS_ID=$(read_plist "$IOS_PLIST" CFBundleIdentifier)
WATCH_ID=$(read_plist "$WATCH_PLIST" CFBundleIdentifier)
IOS_VERSION=$(read_plist "$IOS_PLIST" CFBundleShortVersionString)
WATCH_VERSION=$(read_plist "$WATCH_PLIST" CFBundleShortVersionString)
IOS_BUILD=$(read_plist "$IOS_PLIST" CFBundleVersion)
WATCH_BUILD=$(read_plist "$WATCH_PLIST" CFBundleVersion)
COMPANION_ID=$(read_plist "$WATCH_PLIST" WKCompanionAppBundleIdentifier)

test "$IOS_VERSION" = "$WATCH_VERSION"
test "$IOS_BUILD" = "$WATCH_BUILD"
test "$COMPANION_ID" = "$IOS_ID"

不要用“watchOS Bundle ID 必须以 iOS Bundle ID 开头”代替精确比较。团队可能使用显式标识符,前缀相似并不能证明绑定正确。应从发布配置读取预期值,并验证 WKCompanionAppBundleIdentifier 的完整内容。

兼容含扩展的结构

如果归档中存在 .appex,继续读取它的 WKAppBundleIdentifier,并要求该值等于当前 watchOS App 的 Bundle ID。同时记录扩展自己的版本和构建号,避免某个 Target 漏掉统一版本脚本。

检查签名与可执行架构

外层应用签名成功,不代表每个嵌套组件都可验证。与其只对主应用使用递归检查,不如枚举实际组件,让日志明确指出失败对象:

verify_component() {
  local item="$1"
  codesign --verify --strict --verbose=2 "$item"
  codesign -d --entitlements :- "$item" >/dev/null
}

verify_component "$IOS_APP"
verify_component "$WATCH_APP"

if test -n "${WATCH_EXTENSION:-}"; then
  verify_component "$WATCH_EXTENSION"
fi

随后从各自的 CFBundleExecutable 定位二进制,并用 filelipo -archs 记录架构。门禁不必把某组架构永久写死,因为工具链和部署目标会变化;更可靠的规则是拒绝空可执行文件、拒绝意外的模拟器产物,并保存当前架构清单供审阅。

还要检查嵌套组件中是否出现符号链接越界、重复 Bundle ID 或额外的未声明扩展。这些问题往往来自复制脚本使用了过宽的通配符。

把验收结果变成稳定门禁

脚本退出码只负责阻断,报告负责解释。每次任务应保存组件相对路径、Bundle ID、版本、构建号、架构、签名验证状态和归档校验和。报告中不要收集证书私钥、环境变量全集或完整用户目录路径。

推荐把流程拆成四步:

  1. 生成唯一归档目录,禁止复用上一次产物。
  2. 发现组件,并与仓库中的预期结构比较。
  3. 校验元数据、关联字段、签名和可执行架构。
  4. 输出文本或 JSON 报告,再执行导出任务。

首次启用门禁时,可先运行数次只记录不阻断,确认新旧工程结构都被覆盖;规则稳定后,再将缺失组件、版本错配和签名失败设为硬错误。若同一台云端 Mac 并行归档,必须给每个任务分配独立的 archivePath 和报告目录,避免一个任务读取另一个任务的结果。

最终目标不是增加一轮形式检查,而是让流水线能够证明:主应用、手表应用及其扩展确实来自同一次发布上下文,彼此关系明确,最终归档可以继续进入导出阶段。

常见问题

只检查工程配置,能否代替对 xcarchive 的验收?

不能。构建设置只能说明预期值,xcarchive 才能反映脚本、配置覆盖和签名流程共同作用后的最终产物。

iOS App 与 watchOS App 的营销版本和构建号应如何处理?

同一次发布应保持营销版本和构建号一致。若团队确有独立版本策略,应建立明确的允许关系,不能跳过比较。

独享物理节点

在云端持续运行你的 Mac mini 工作流

选择 RunAMac M4、租用周期与部署节点,将开发、构建或实验任务放到一台独享物理机上。

选择租用方案