团队把 iOS 工程迁到云端 Mac 后,构建通过并不代表隐私声明完整。依赖升级可能新增 PrivacyInfo.xcprivacy,资源复制设置也可能让主工程的清单没有进入最终 App。更稳妥的做法,是把隐私清单当成构建产物的一部分:先检查源码,再检查生成物,最后用版本化基线拦截未经审查的变化。
先定义门禁检查范围
一条可执行的门禁至少覆盖四层:清单能否被 plist 解析、顶层字段类型是否正确、所有依赖清单是否进入检查范围、最终 App 中是否存在预期清单。不要只在仓库根目录查一个文件,源码依赖、预编译框架和包管理器检出的组件都可能各自携带声明。
先在干净工作区建立清单索引:
find . \
-path './.git' -prune -o \
-path './DerivedData' -prune -o \
-name PrivacyInfo.xcprivacy -print \
| LC_ALL=C sort > privacy-manifests.current
建议把项目明确维护的路径写入 privacy-manifests.baseline。流水线比较两个文件;出现新增、删除或路径变化时先失败,由代码所有者确认后再更新基线。这样不会把依赖更新造成的声明变化静默吞掉。
基线不是合规证明。它只证明当前变化经过了显式审查,不能替代对代码真实行为的判断。
校验 plist 与顶层结构
plutil -lint 能发现 XML 或二进制 plist 损坏,但不会替你判断字段类型。可以用 Python 标准库增加一层轻量结构检查,不需要安装额外依赖:
import pathlib
import plistlib
import sys
allowed = {
"NSPrivacyTracking": bool,
"NSPrivacyTrackingDomains": list,
"NSPrivacyCollectedDataTypes": list,
"NSPrivacyAccessedAPITypes": list,
}
failed = False
files = sorted(pathlib.Path(".").rglob("PrivacyInfo.xcprivacy"))
if not files:
print("No privacy manifest found")
sys.exit(1)
for path in files:
try:
with path.open("rb") as stream:
data = plistlib.load(stream)
if not isinstance(data, dict):
raise TypeError("Root must be a dictionary")
for key, value in data.items():
expected = allowed.get(key)
if expected is None:
raise KeyError(f"Unknown top-level key: {key}")
if not isinstance(value, expected):
raise TypeError(f"{key} must be {expected.__name__}")
print(path)
except Exception as error:
failed = True
print(f"{path}: {error}")
sys.exit(1 if failed else 0)
把脚本保存为 Scripts/validate_privacy_manifests.py,在构建前执行。若项目使用了组织内部扩展字段,不应直接放宽所有未知键,而应逐项加入允许列表并记录用途。
建立 Required Reason API 复核表
结构正确仍不等于理由正确。文件时间戳、系统启动时间、磁盘空间和偏好设置等调用都应进入代码复核范围。自动扫描可以提供线索,但宏、封装层和二进制依赖会产生漏报或误报,因此不要让一次文本搜索直接决定理由代码。
为每个声明维护一行可审计记录:
| 检查项 | 应记录内容 | 失败条件 |
|---|---|---|
| API 类别 | 清单中的类别标识 | 类别无对应代码路径 |
| 使用位置 | 模块、文件与负责人 | 无法定位调用来源 |
| 使用目的 | 面向用户的实际功能 | 理由与行为不一致 |
| 依赖来源 | 自有代码或具体组件 | 二进制来源不明确 |
| 复核触发 | 代码或依赖版本变化 | 变更后未重新确认 |
对于闭源二进制组件,至少记录组件版本、清单哈希和引入它的业务功能。无法解释来源的声明不应靠猜测补齐,应回到依赖负责人处确认。
在最终 App 中再次检查
源码中的文件可能因 target membership 或资源阶段配置错误而未被复制。流水线应完成一次无签名 Release 构建,然后检查真实产物:
rm -rf .build/privacy
xcodebuild \
-scheme "$SCHEME" \
-configuration Release \
-sdk iphoneos \
-derivedDataPath .build/privacy \
CODE_SIGNING_ALLOWED=NO \
build
APP_PATH="$(find .build/privacy/Build/Products -type d -name '*.app' -print -quit)"
test -n "$APP_PATH"
find "$APP_PATH" -name PrivacyInfo.xcprivacy -print | LC_ALL=C sort
主 App、嵌入框架和扩展应按工程预期分别出现。不要写死 DerivedData 的随机目录,也不要只检查源码数量,因为多个源码清单可能在构建过程中被合并、替换或遗漏。
保存产物清单
将产物内每个清单的相对路径和 SHA-256 保存为流水线附件:
find "$APP_PATH" -name PrivacyInfo.xcprivacy -print0 |
while IFS= read -r -d '' file; do
relative="${file#"$APP_PATH"/}"
digest="$(shasum -a 256 "$file" | awk '{print $1}')"
printf '%s %s
' "$digest" "$relative"
done | LC_ALL=C sort > privacy-artifact.sha256
发生审核问题时,这份记录可以回答“当次构建实际包含了什么”,比仅查看当前分支可靠。
把失败条件接入日常流程
建议将检查拆成快速与完整两档。合并请求先运行清单发现、plist 解析、字段类型和基线差异;主分支再执行 Release 构建与 App 产物复核。快速检查失败时通常几十秒内就能给出路径,完整检查则负责发现资源阶段和嵌入框架问题。
上线前保留以下检查项:
- 清单索引与核准基线一致。
- 每个文件均通过
plutil -lint和结构脚本。 - 新增 API 类别已关联代码位置、负责人和真实用途。
- 依赖升级后的清单差异已经人工复核。
- 最终 App、扩展与嵌入框架中的清单符合预期。
- 产物路径和哈希已随本次构建归档。
这套门禁的目标不是自动替团队作出合规判断,而是让遗漏变成明确的构建失败,让每次声明变化都能追溯到代码、依赖和审查记录。
常见问题
项目里存在 PrivacyInfo.xcprivacy 就足够了吗?
不够。还要确认它能被解析、字段类型正确,并且实际进入最终应用或对应框架的构建产物。
自动脚本能判断每个 Required Reason API 理由是否正确吗?
不能完全判断。脚本适合发现缺失、格式错误和未审查变更,理由是否符合真实调用场景仍需代码所有者复核。
依赖升级后为什么要重新生成隐私清单基线?
依赖可能新增或修改自己的隐私清单。只有在审查差异并确认声明与代码行为一致后,才应更新基线。
为下一次构建选择独享云端 Mac
按工作负载选择 Mac mini 机型、租期与区域。每笔订单对应独立物理节点,实际可用性以控制台实时返回为准。