工程文章

云端 Mac 上的 iOS App Extension 嵌入一致性门禁

云端 Mac 上的 iOS App Extension 嵌入一致性门禁

一个包含通知服务、分享或 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 和导出配置修正,再重新生成完整归档。

失败时按顺序保留证据

门禁失败后,不要立即清理工作目录。先保存以下信息:

  1. .xcarchive 内所有 .app.appex 的相对路径;
  2. 每个包的版本号、最低系统版本和 Bundle Identifier;
  3. codesign --verify --strict --verbose=2 的完整输出;
  4. 本次 Scheme、Configuration 与实际展开后的构建设置;
  5. 归档任务使用的提交标识和 Xcode 版本。

可用下面的命令导出构建设置,便于比较主应用和扩展实际拿到的变量:

xcodebuild \
  -workspace App.xcworkspace \
  -scheme App \
  -configuration Release \
  -showBuildSettings > build/build-settings.txt

最终门禁应做到两点:失败信息能直接指出哪个 .appex、哪个键不一致;修复必须发生在工程配置中,而不是发生在制品生成之后。这样无论云端 Mac 节点如何更换,验收依据都跟随仓库,归档结果也能重复检查。

常见问题

为什么 Xcode 归档成功后仍可能因 App Extension 失败?

归档阶段主要确认目标能够构建,导出和安装阶段还会检查主应用与扩展的版本、部署目标、扩展声明、嵌入关系及签名完整性,因此错误可能延后出现。

主应用与 App Extension 的版本号必须完全一致吗?

CFBundleVersion 应保持一致。CFBundleShortVersionString 也建议由同一构建变量生成,避免发布记录、崩溃定位和制品追踪出现歧义。

可以在归档后直接重新签名修复扩展吗?

不建议。归档后补签容易掩盖工程配置漂移,应修正构建设置、嵌入阶段或签名配置,再从干净工作区重新生成归档。

独享物理节点

把开发与构建任务放到独享云端 Mac

按任务选择机型、节点区域和租用周期。每份实例对应独享物理机,是非虚拟机环境。

选择云端 Mac 方案