一个包含通知服务、分享或 Widget 扩展的 iOS 工程,常会出现这种故障:主应用编译和归档都成功,到了导出、安装或发布检查阶段才报告扩展无效。根因通常不是源码,而是 .app 与 .appex 之间的版本号、部署目标、嵌入位置或签名发生漂移。把检查放到云端 Mac 的归档任务后面,可以在制品离开构建节点前给出明确结论。
先定义归档验收边界
App Extension 不是独立交付物。它必须位于主应用的 PlugIns 目录,并与主应用形成可验证的嵌入关系。CI 至少应检查以下项目:
| 检查项 | 主应用 | App Extension | 建议规则 |
|---|---|---|---|
CFBundleVersion |
必有 | 必有 | 完全一致 |
CFBundleShortVersionString |
必有 | 必有 | 完全一致 |
MinimumOSVersion |
必有 | 必有 | 团队统一时保持一致 |
NSExtension |
不要求 | 必有 | 字典存在且可读取 |
| 代码签名 | 有效 | 有效 | 严格验证通过 |
| 嵌入位置 | Applications |
PlugIns/*.appex |
不接受散落副本 |
最低系统版本并非所有项目都必须相同,但若团队没有刻意维护不同目标,直接要求一致更容易发现配置漂移。确有差异时,应把允许范围写进仓库,而不是在脚本里静默忽略。
“构建成功”只说明各 Target 能生成产物;“归档合格”还要证明这些产物以正确关系组成了一个可交付应用。
生成唯一待检的 xcarchive
门禁应读取归档,而不是读取 DerivedData 中间目录。后者可能残留上一次构建的 .appex,也不能代表最终嵌入结构。先删除固定归档路径,再执行一次 Release 归档:
set -euo pipefail
ARCHIVE_PATH="$PWD/build/App.xcarchive"
rm -rf "$ARCHIVE_PATH"
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE_PATH" \
clean archive
不要让不同分支共用同一个归档目录。并行任务可使用提交哈希或 CI 任务编号创建独立路径,但进入后续脚本前,应只传入一个确定的 .xcarchive。
先确认嵌入结果
归档完成后,主应用通常位于 Products/Applications,扩展位于主应用包内的 PlugIns。先用 find 查看真实结果:
find build/App.xcarchive/Products/Applications \
\( -name '*.app' -o -name '*.appex' \) \
-print
如果工程声明了扩展 Target,却没有找到 .appex,优先检查主应用 Target 的 Embed App Extensions 构建阶段,以及扩展是否被当前 Scheme 构建。不要从其他目录复制扩展来“补齐”归档。
用脚本核对元数据与签名
下面的 Bash 脚本兼容 macOS 自带工具。它定位主应用,逐个读取扩展的 Info.plist,比较版本与最低系统版本,并分别验证签名:
#!/bin/bash
set -euo pipefail
ARCHIVE="${1:?usage: validate-extensions.sh App.xcarchive}"
APP=$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)
if [ -z "$APP" ]; then
printf '%s
' "main app not found"
exit 1
fi
read_plist() {
/usr/libexec/PlistBuddy -c "Print :$2" "$1/Info.plist"
}
host_build=$(read_plist "$APP" CFBundleVersion)
host_version=$(read_plist "$APP" CFBundleShortVersionString)
host_minimum=$(read_plist "$APP" MinimumOSVersion)
count=0
failed=0
while IFS= read -r extension; do
count=$((count + 1))
ext_build=$(read_plist "$extension" CFBundleVersion)
ext_version=$(read_plist "$extension" CFBundleShortVersionString)
ext_minimum=$(read_plist "$extension" MinimumOSVersion)
[ "$ext_build" = "$host_build" ] || failed=1
[ "$ext_version" = "$host_version" ] || failed=1
[ "$ext_minimum" = "$host_minimum" ] || failed=1
/usr/libexec/PlistBuddy \
-c "Print :NSExtension" \
"$extension/Info.plist" >/dev/null
codesign --verify --strict --verbose=2 "$extension"
done < <(find "$APP/PlugIns" -maxdepth 1 -name '*.appex' -print)
[ "$count" -gt 0 ] || {
printf '%s
' "no app extensions found"
exit 1
}
codesign --verify --deep --strict --verbose=2 "$APP"
[ "$failed" -eq 0 ] || {
printf '%s
' "extension metadata mismatch"
exit 1
}
把脚本保存为 ci/validate-extensions.sh,执行 chmod +x 后,在归档步骤之后调用。若仓库同时产出“有扩展”和“无扩展”两个 Scheme,应通过参数声明预期扩展数量,不能把零扩展统一视为成功。
把版本源收敛到构建设置
最常见的漂移来自手工维护多个 Info.plist。主应用改了 Build Number,扩展仍保留旧值,源码编译不会因此失败。更稳妥的方式是让所有 Target 使用同一组构建变量:
MARKETING_VERSION = 4.8.0
CURRENT_PROJECT_VERSION = 8204
IPHONEOS_DEPLOYMENT_TARGET = 17.0
对应的 plist 值使用 $(MARKETING_VERSION)、$(CURRENT_PROJECT_VERSION) 和 $(IPHONEOS_DEPLOYMENT_TARGET)。若配置由 .xcconfig 管理,应让主应用与扩展共同包含基础文件,再由少量 Target 专属文件覆盖真正需要不同的键。
不要用归档后补签掩盖问题
codesign --deep 适合验证整个包,不适合被当成通用修复命令。归档后递归重签可能改变原有嵌入关系,还会让 CI 产物与工程配置脱节。发现签名失败时,应回到 Target 的签名设置、Build Phases 和导出配置修正,再重新生成完整归档。
失败时按顺序保留证据
门禁失败后,不要立即清理工作目录。先保存以下信息:
.xcarchive内所有.app与.appex的相对路径;- 每个包的版本号、最低系统版本和 Bundle Identifier;
codesign --verify --strict --verbose=2的完整输出;- 本次 Scheme、Configuration 与实际展开后的构建设置;
- 归档任务使用的提交标识和 Xcode 版本。
可用下面的命令导出构建设置,便于比较主应用和扩展实际拿到的变量:
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings > build/build-settings.txt
最终门禁应做到两点:失败信息能直接指出哪个 .appex、哪个键不一致;修复必须发生在工程配置中,而不是发生在制品生成之后。这样无论云端 Mac 节点如何更换,验收依据都跟随仓库,归档结果也能重复检查。
常见问题
为什么 Xcode 归档成功后仍可能因 App Extension 失败?
归档阶段主要确认目标能够构建,导出和安装阶段还会检查主应用与扩展的版本、部署目标、扩展声明、嵌入关系及签名完整性,因此错误可能延后出现。
主应用与 App Extension 的版本号必须完全一致吗?
CFBundleVersion 应保持一致。CFBundleShortVersionString 也建议由同一构建变量生成,避免发布记录、崩溃定位和制品追踪出现歧义。
可以在归档后直接重新签名修复扩展吗?
不建议。归档后补签容易掩盖工程配置漂移,应修正构建设置、嵌入阶段或签名配置,再从干净工作区重新生成归档。
独享物理节点
把开发与构建任务放到独享云端 Mac
按任务选择机型、节点区域和租用周期。每份实例对应独享物理机,是非虚拟机环境。