同一个 iOS 工程在本地归档正常,换到云端 Mac 的 Release 流水线后,却可能带上错误的 Bundle ID、调试 URL Scheme,或者缺少相机权限说明。问题通常不在仓库里那份 Info.plist,而在 Xcode 合并构建设置、生成配置和目标属性后写入 App 的最终结果。可靠的 CI 应该审计构建产物,而不是只对源文件做文本检查。
先确认真正被交付的配置
现代 Xcode 工程可能启用自动生成 Info.plist,也可能由不同 target、configuration 和 .xcconfig 提供值。INFOPLIST_FILE 指向的文件只是输入之一,PRODUCT_BUNDLE_IDENTIFIER、MARKETING_VERSION、CURRENT_PROJECT_VERSION 等设置会在构建时参与合并。
先用明确的 workspace、scheme、configuration 和输出目录执行构建:
set -euo pipefail
ROOT="$(pwd)"
DERIVED_DATA="$ROOT/.ci/DerivedData"
xcodebuild \
-workspace Example.xcworkspace \
-scheme Example \
-configuration Release \
-sdk iphoneos \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO \
build
这里关闭签名只适合做快速配置审计,不代表发布归档也应关闭签名。构建完成后,不要猜 App 路径,可以从构建设置读取产品目录和名称:
SETTINGS="$(mktemp)"
xcodebuild \
-workspace Example.xcworkspace \
-scheme Example \
-configuration Release \
-sdk iphoneos \
-derivedDataPath "$DERIVED_DATA" \
-showBuildSettings > "$SETTINGS"
BUILD_DIR="$(awk -F ' = ' '/ TARGET_BUILD_DIR = /{print $2; exit}' "$SETTINGS")"
WRAPPER_NAME="$(awk -F ' = ' '/ WRAPPER_NAME = /{print $2; exit}' "$SETTINGS")"
APP_PATH="$BUILD_DIR/$WRAPPER_NAME"
PLIST_PATH="$APP_PATH/Info.plist"
test -d "$APP_PATH"
test -f "$PLIST_PATH"
plutil -lint "$PLIST_PATH"
审计对象应当是本次任务刚生成的 App。复用上一次构建留下的固定路径,会让门禁对旧产物给出错误结论。
把期望值放进版本控制
不要把大量期望值硬编码在 CI 平台界面里。更容易审查的做法,是为每个发布环境保存一份小型 JSON,例如 ci/plist-release.json:
{
"bundleIdentifier": "com.example.product",
"urlSchemes": ["example"],
"requiredUsageKeys": [
"NSCameraUsageDescription",
"NSPhotoLibraryUsageDescription"
],
"allowedBackgroundModes": ["remote-notification"]
}
这里只记录适合公开进入仓库的规则,不放令牌、私钥或签名密码。版本号和构建号通常由流水线参数产生,脚本应检查格式及其与构建输入的一致性,而不是长期写死某个数字。
不同 App target、扩展和测试宿主应有各自规则。不要让主 App 的 Bundle ID 规则误判 Widget,也不要因为扩展不需要相机权限,就复用一份包含相机说明的清单。
编写可失败的审计脚本
plutil -extract 适合读取字典和数组,PlistBuddy 适合读取简单标量。下面的脚本展示最小骨架:
#!/bin/bash
set -euo pipefail
PLIST_PATH="${1:?missing Info.plist path}"
EXPECTED_BUNDLE_ID="${EXPECTED_BUNDLE_ID:?missing bundle id}"
EXPECTED_VERSION="${EXPECTED_VERSION:?missing version}"
EXPECTED_BUILD="${EXPECTED_BUILD:?missing build number}"
read_key() {
/usr/libexec/PlistBuddy -c "Print :$1" "$PLIST_PATH" 2>/dev/null
}
require_key() {
local key="$1"
local value
value="$(read_key "$key" || true)"
if [[ -z "$value" ]]; then
printf 'Missing required key: %s
' "$key" >&2
exit 1
fi
}
[[ "$(read_key CFBundleIdentifier)" == "$EXPECTED_BUNDLE_ID" ]]
[[ "$(read_key CFBundleShortVersionString)" == "$EXPECTED_VERSION" ]]
[[ "$(read_key CFBundleVersion)" == "$EXPECTED_BUILD" ]]
require_key NSCameraUsageDescription
require_key NSPhotoLibraryUsageDescription
SCHEMES_JSON="$(plutil -extract CFBundleURLTypes json -o - "$PLIST_PATH")"
printf '%s' "$SCHEMES_JSON" | grep -q '"example"'
MODES="$(plutil -extract UIBackgroundModes raw -o - "$PLIST_PATH" 2>/dev/null || true)"
if [[ "$MODES" == *"audio"* ]]; then
printf 'Unexpected background mode: audio
' >&2
exit 1
fi
生产脚本还应输出键名、期望值和实际值,但不要打印完整 plist。权限文案、查询 Scheme 和第三方配置中可能出现内部标识,原样上传日志会扩大暴露范围。
正向规则与反向规则要同时存在
“必须存在”只能发现漏配,发现不了调试项泄漏。建议同时维护禁止清单,例如禁止 Release 产物出现测试服务器标记、调试 URL Scheme、文件共享开关或未批准的后台模式。
数组字段不要用简单字符串包含判断作为最终实现。更稳妥的方式是用 plutil -extract ... json 输出 JSON,再交给 Ruby、Python 或项目已有脚本逐项比较,并明确处理字段不存在、类型错误和重复值三种情况。
覆盖 App、扩展与归档两道检查
一个归档可能同时包含主 App、Widget、通知服务扩展和其他 .appex。只检查主 App 会漏掉扩展的标识符、版本号或权限配置。归档完成后可以遍历所有 bundle:
find "$ARCHIVE_PATH/Products/Applications" \
\( -name "*.app" -o -name "*.appex" \) -print0 |
while IFS= read -r -d '' bundle; do
plist="$bundle/Info.plist"
plutil -lint "$plist"
printf 'Auditing %s
' "$bundle"
done
建议设置两道门禁:普通 Release build 后做快速检查,尽早反馈;archive 完成后再检查归档内产物,作为发布判定。第二道不能省,因为归档动作可能使用不同 configuration、导出参数或流水线变量。
| 检查对象 | 重点字段 | 失败处理 |
|---|---|---|
| 主 App | 标识符、版本、权限文案、URL Scheme | 阻断归档 |
| App Extension | 标识符前缀、版本、扩展类型 | 阻断归档 |
| 最终 Archive | 全部 bundle 与生产禁用项 | 阻断发布 |
处理常见误报并留下证据
最常见的误报来自空字符串、布尔值类型和数组顺序。权限说明不能只判断键存在,还要去掉首尾空白后确认非空;布尔值应按 plist 类型读取,不要把字符串 "false" 当作布尔值;URL Scheme 和后台模式通常只比较集合,不应依赖数组顺序。
脚本失败时保留一份脱敏后的审计报告,内容包括 scheme、configuration、SDK、App 路径、失败键名和哈希。不要保存整份构建环境,也不要把所有环境变量打进日志。若同一台云端 Mac 执行多个任务,每个任务都使用独立 DerivedData,并在审计前确认产物修改时间属于当前任务。
最后,把审计脚本当作代码维护:规则变更走合并审查,为“缺键、错类型、禁止值、扩展遗漏”各准备一个测试样例。这样 Info.plist 的问题会在流水线里变成明确、可复现的失败,而不是等到归档或提交阶段再靠人工猜测。
常见问题
为什么不能只审查仓库中的 Info.plist?
因为 Xcode 会合并构建设置、生成值和不同配置文件。真正随 App 交付的是构建产物内的 Info.plist,它才是审计基准。
哪些 Info.plist 字段最适合先接入 CI 门禁?
优先检查 CFBundleIdentifier、版本号、构建号、权限说明、URL Scheme 和 UIBackgroundModes,并先阻断缺失值与生产环境禁用值。
脚本应该在 archive 之前还是之后运行?
快速反馈可在普通构建后执行一次,发布流水线还应在 archive 完成后对归档中的 App 再执行一次,以最终归档结果为准。
需要一台独享 Apple Silicon 构建机?
按天、周、月或季租用独享物理节点,用于 Xcode 构建、iOS CI、远程开发和自动化任务。