ENGINEERING NOTE

在云端 Mac 上建立 iOS 权限签名预检

在云端 Mac 上建立 iOS 权限签名预检

一次归档在 Xcode 里显示成功,上传时却因推送环境、关联域或应用标识不匹配而失败,问题通常不在编译,而在三个来源没有对齐:工程声明的权限、描述文件允许的权限、最终写入 App 的签名权限。云端 Mac 经常由脚本复用,同一节点可能处理多个分支和环境,因此不能只看仓库里的 .entitlements 文件,应该直接检查准备交付的 .xcarchive

先确定检查对象

以下流程以已经生成的归档为输入。先定位主 App,不要直接检查 DerivedData 里的临时产物,因为它可能尚未完成最终签名。

ARCHIVE_PATH="$HOME/builds/MyApp.xcarchive"
APP_PATH="$ARCHIVE_PATH/Products/Applications/MyApp.app"

test -d "$APP_PATH"
test -f "$APP_PATH/embedded.mobileprovision"
codesign --verify --strict --verbose=2 "$APP_PATH"

如果 App 包含扩展,还要分别验证 .appex。主 App 验证通过,不代表通知扩展、Widget 或共享扩展使用了相容的应用标识与权限。

find "$APP_PATH/PlugIns" -type d -name '*.appex' -print0 2>/dev/null |
while IFS= read -r -d '' item; do
  codesign --verify --strict --verbose=2 "$item"
done

预检的判断依据应是“将要上传的产物”,而不是“工程看起来配置正确”。前者是结果,后者只是输入。

提取两份真实权限清单

codesign 可读取最终签名中的 entitlements,security cms 可解码 App 内嵌的描述文件。将两者保存为 plist,后续比较时不必依赖 Xcode 界面。

WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT

codesign -d --entitlements :- "$APP_PATH" \
  > "$WORK_DIR/app-entitlements.plist" 2>/dev/null

security cms -D -i "$APP_PATH/embedded.mobileprovision" \
  > "$WORK_DIR/profile.plist"

plutil -lint "$WORK_DIR/app-entitlements.plist"
plutil -lint "$WORK_DIR/profile.plist"

不要拿描述文件顶层字段直接与 App 比较。允许的权限位于 Entitlements 字典中;创建时间、名称等字段只适合辅助定位,不代表签名结果。

自动比较关键字段

下面的脚本检查五类高频问题。字符串支持描述文件中的 * 通配范围;数组权限要求 App 的每一项都被描述文件允许。脚本返回非零状态,适合接在归档步骤之后。

python3 - "$WORK_DIR/app-entitlements.plist" "$WORK_DIR/profile.plist" <<'PY'
import fnmatch
import plistlib
import sys

with open(sys.argv[1], "rb") as f:
    app = plistlib.load(f)

with open(sys.argv[2], "rb") as f:
    profile = plistlib.load(f)["Entitlements"]

keys = [
    "application-identifier",
    "com.apple.developer.team-identifier",
    "aps-environment",
    "com.apple.developer.associated-domains",
    "get-task-allow",
]

def allowed(actual, expected):
    if isinstance(actual, list) and isinstance(expected, list):
        return all(any(fnmatch.fnmatch(str(v), str(rule)) for rule in expected) for v in actual)
    if isinstance(actual, str) and isinstance(expected, str):
        return fnmatch.fnmatch(actual, expected)
    return actual == expected

failed = []
for key in keys:
    if key not in app:
        continue
    if key not in profile or not allowed(app[key], profile[key]):
        failed.append(key)

if failed:
    print("Entitlement mismatch:", ", ".join(failed))
    raise SystemExit(1)

print("Entitlement preflight passed")
PY

脚本只比较 App 实际声明的字段。若项目规定某项必须存在,例如生产包必须包含 aps-environment,还应增加必填字段列表,缺失时同样失败。

按症状定位配置来源

症状 优先检查 常见根因
推送能力异常 aps-environment 归档环境与描述文件用途不一致
通用链接失效 com.apple.developer.associated-domains 域名项未写入最终签名或扩展配置不同
上传时报标识不匹配 application-identifier Bundle Identifier、团队前缀或目标配置串用
发布包仍可调试 get-task-allow 使用了不适合交付的构建配置
扩展安装失败 主 App 与 .appex 权限 扩展标识、应用组或描述文件组合不相容

遇到失败时,先保留归档和两份 plist,再检查 CODE_SIGN_ENTITLEMENTSPRODUCT_BUNDLE_IDENTIFIER 与当前构建配置。不要立即重签覆盖现场,否则最有价值的差异证据会消失。

关联域和应用组是数组,顺序通常不重要,应比较集合包含关系;布尔字段必须按真实布尔值读取,不能把字符串 "false" 当作 false。这也是使用 plistlib 而不是 grep 的原因。

接入云端构建验收

将脚本存为 Scripts/verify_entitlements.sh,在 xcodebuild archive 成功后、导出或上传前运行。每次任务使用独立归档目录,并把检查结果、App 的签名摘要和失败字段作为构建附件保存。日志只记录字段名与脱敏后的标识,不要输出私钥内容或完整访问凭据。

建议把验收拆成四步:验证包结构、提取签名权限、解码描述文件、执行策略比较。任一步失败都停止后续交付,但保留 .xcarchive 供复盘。多 Target 工程则为主 App 和每个扩展分别执行,不能复用主 App 的检查结果。

最后再做一次人工抽查:确认当前任务使用了预期 Scheme 与 Configuration,生产产物的 get-task-allowfalse,推送环境符合交付目标,关联域没有混入测试地址,所有扩展均通过独立签名验证。这样能把原本拖到上传阶段的错误,收敛到可重复、可追踪的构建检查中。

常见问题

为什么只检查工程里的 entitlements 文件还不够?

工程文件只是签名前的输入。构建设置、描述文件和签名过程都可能改变最终结果,应以归档中 App 的 codesign 输出及 embedded.mobileprovision 为准。

哪些权限最适合先加入自动预检?

优先检查 application-identifier、com.apple.developer.team-identifier、aps-environment、com.apple.developer.associated-domains 与 get-task-allow。

预检通过是否代表应用一定能成功发布?

不能。它只能确认本地产物的签名结构和关键权限相容,版本、隐私声明及服务端配置仍需由后续发布检查验证。

独享物理节点

把下一次构建放到云端 Mac 上运行。

比较三档 Apple Silicon 配置,在五个海外节点中选择适合当前工作流的区域。

选择租用方案