一个 App Clip 在开发机上能从 Scheme 启动,不代表交付物正确。最常见的漏检不是编译失败,而是 Clip 没有进入宿主归档、父子标识不一致,或调用 URL 只覆盖了理想路径。把这些检查放到云端 Mac 的持续集成任务里,才能让每次提交使用同一套 Xcode、模拟器和验收脚本。
先定义可验证的交付结果
不要把“Build Succeeded”当作唯一结果。一次完整验收至少应产生宿主 .xcarchive、App Clip 独立构建日志、解析后的权限文件,以及调用场景记录。建议把测试 URL 放在仓库配置中,而不是散落在 Scheme 的个人设置里。
| 检查层 | 输入 | 通过条件 |
|---|---|---|
| 编译 | App Clip Scheme | 模拟器目标可构建 |
| 嵌入 | 宿主归档 | AppClips 下存在 Clip |
| 关联 | 两个应用的标识与权限 | 父子关系一致 |
| 调用 | 基础、参数化、错误 URL | 路由与回退符合预期 |
单独启动 Clip 验证的是业务入口;归档宿主再检查嵌入关系,验证的才是可交付结构。两者不能互相替代。
把构建目录固定下来
云端任务应使用独立 DerivedData,避免上一轮产物掩盖复制阶段错误。先构建 App Clip 的模拟器版本,再归档宿主。Scheme 名称通过环境变量传入,脚本便能复用于不同分支。
set -euo pipefail
ROOT="$PWD"
OUT="$ROOT/.ci-artifacts"
DERIVED="$OUT/DerivedData"
ARCHIVE="$OUT/HostApp.xcarchive"
rm -rf "$OUT"
mkdir -p "$OUT"
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$CLIP_SCHEME" \
-sdk iphonesimulator \
-destination "$SIMULATOR_DESTINATION" \
-derivedDataPath "$DERIVED" \
clean build | tee "$OUT/app-clip-build.log"
xcodebuild \
-workspace "$WORKSPACE" \
-scheme "$HOST_SCHEME" \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE" \
archive | tee "$OUT/host-archive.log"
SIMULATOR_DESTINATION 可以固定为流水线已安装的设备类型和系统版本。升级 Xcode 后先更新基线任务,不要让普通功能提交顺带改变测试运行时。
检查宿主与 Clip 的包关系
定位嵌入产物
归档完成后,从 Products/Applications 找到宿主 .app,再确认其 AppClips 目录中恰好存在预期目标。不要依赖固定应用文件名,可按扩展名查找并检查结果数量。
HOST_APP="$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' -print -quit)"
CLIP_APP="$(find "$HOST_APP/AppClips" -maxdepth 1 -name '*.app' -print -quit)"
test -n "$HOST_APP"
test -n "$CLIP_APP"
HOST_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$HOST_APP/Info.plist")"
CLIP_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$CLIP_APP/Info.plist")"
printf '%s
' "$HOST_ID" > "$OUT/host-bundle-id.txt"
printf '%s
' "$CLIP_ID" > "$OUT/clip-bundle-id.txt"
如果 AppClips 目录不存在,优先检查宿主 Target 的嵌入阶段、Clip Target 是否属于当前归档 Scheme,以及 Release 配置是否覆盖了 Debug 中有效的设置。
比较最终权限
工程文件里的声明不一定等于签入产物的最终结果。使用 codesign -d --entitlements :- 分别导出宿主和 Clip 权限,再由 plutil 转成稳定格式。门禁应确认 Clip 的父应用关联值指向当前宿主,同时限制不应继承的能力。
codesign -d --entitlements :- "$HOST_APP" > "$OUT/host-entitlements.plist"
codesign -d --entitlements :- "$CLIP_APP" > "$OUT/clip-entitlements.plist"
plutil -lint "$OUT/host-entitlements.plist"
plutil -lint "$OUT/clip-entitlements.plist"
不要只做整文件文本比较,因为 Xcode 版本变化可能调整键顺序。应提取需要保持稳定的键逐项比较,并把实际值写入构建产物,便于失败后复查。
用三类 URL 验证路由
调用测试至少覆盖基础入口、合法参数和异常输入。例如基础入口应打开默认轻量场景;带资源编号的 URL 应进入指定内容;缺失或非法参数则应回到安全页面,而不是停留在空白状态。
让 URL 成为测试输入
在 App Clip Scheme 中使用 _XCAppClipURL 适合人工调试,但持续集成不应依赖开发者本地 Scheme 状态。更稳妥的做法是让路由解析器接收 URL,用单元测试直接覆盖输入,再保留一条模拟器冒烟任务验证应用生命周期。
func testInvocationRoutes() throws {
let base = try XCTUnwrap(URL(string: invocationBaseURL))
XCTAssertEqual(router.route(for: base), .home)
let item = try XCTUnwrap(URL(string: invocationItemURL))
XCTAssertEqual(router.route(for: item), .item(id: "42"))
let malformed = try XCTUnwrap(URL(string: invocationMalformedURL))
XCTAssertEqual(router.route(for: malformed), .fallback)
}
这样可以把域名解析、路径匹配、查询参数校验与界面启动拆开。路由单元测试负责完整分支,模拟器任务只确认真实进程能够启动并呈现首屏,失败定位会更直接。
收紧门禁并保留失败现场
门禁失败时至少归档 xcodebuild 日志、两个 Info.plist、导出的权限文件、宿主与 Clip 标识,以及失败用例名称。日志中若包含访问令牌、临时凭据或本地路径中的敏感片段,应先脱敏再上传。
建议将检查顺序固定为:清理独立构建目录、构建 Clip、归档宿主、检查嵌入、比较关键权限、执行路由测试、运行模拟器冒烟。前置结构检查失败时立即停止,避免在错误归档上继续消耗模拟器时间。
GPUMini 上的任务也应显式记录 xcodebuild -version、所选 SDK 和模拟器运行时。真正需要长期维护的不是某一次成功截图,而是一组输入明确、产物可追溯、失败后能快速复现的检查项。
常见问题
只构建 App Clip 目标能证明归档正确吗?
不能。独立构建只能发现编译和资源问题,还必须归档宿主应用,并检查 AppClips 目录、父子应用标识及最终权限文件。
调用 URL 应该只测试一个固定地址吗?
不应该。至少准备基础路径、带参数路径和非法参数三类用例,分别验证正常路由、参数解析和安全回退。
为什么本地可以启动,持续集成却应继续检查包结构?
本地运行可能直接使用 App Clip Scheme,绕过宿主归档中的嵌入关系。包结构检查能发现复制阶段、标识和权限配置漂移。
为开发与构建任务选择一台云端 Mac
比较两档 M4 配置、四种租用周期与四个在售节点,再按任务需要完成部署。