一个同时包含 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,再读取 CFBundleIdentifier、CFBundleShortVersionString 和 CFBundleVersion。同一次发布通常应让 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 定位二进制,并用 file 或 lipo -archs 记录架构。门禁不必把某组架构永久写死,因为工具链和部署目标会变化;更可靠的规则是拒绝空可执行文件、拒绝意外的模拟器产物,并保存当前架构清单供审阅。
还要检查嵌套组件中是否出现符号链接越界、重复 Bundle ID 或额外的未声明扩展。这些问题往往来自复制脚本使用了过宽的通配符。
把验收结果变成稳定门禁
脚本退出码只负责阻断,报告负责解释。每次任务应保存组件相对路径、Bundle ID、版本、构建号、架构、签名验证状态和归档校验和。报告中不要收集证书私钥、环境变量全集或完整用户目录路径。
推荐把流程拆成四步:
- 生成唯一归档目录,禁止复用上一次产物。
- 发现组件,并与仓库中的预期结构比较。
- 校验元数据、关联字段、签名和可执行架构。
- 输出文本或 JSON 报告,再执行导出任务。
首次启用门禁时,可先运行数次只记录不阻断,确认新旧工程结构都被覆盖;规则稳定后,再将缺失组件、版本错配和签名失败设为硬错误。若同一台云端 Mac 并行归档,必须给每个任务分配独立的 archivePath 和报告目录,避免一个任务读取另一个任务的结果。
最终目标不是增加一轮形式检查,而是让流水线能够证明:主应用、手表应用及其扩展确实来自同一次发布上下文,彼此关系明确,最终归档可以继续进入导出阶段。
常见问题
只检查工程配置,能否代替对 xcarchive 的验收?
不能。构建设置只能说明预期值,xcarchive 才能反映脚本、配置覆盖和签名流程共同作用后的最终产物。
iOS App 与 watchOS App 的营销版本和构建号应如何处理?
同一次发布应保持营销版本和构建号一致。若团队确有独立版本策略,应建立明确的允许关系,不能跳过比较。
在云端持续运行你的 Mac mini 工作流
选择 RunAMac M4、租用周期与部署节点,将开发、构建或实验任务放到一台独享物理机上。