同一提交在昨天通过、今天却拉到不同的间接依赖,是云端 Mac 构建中很难追责的一类问题。故障表面可能只是编译错误,真正变化的却可能是包修订、下载内容或新增的脚本阶段。处理它不能只靠“重新执行”,而要让每次构建都能回答三个问题:解析了什么、内容是否变化、变化是否经过审核。
先定义需要留下的证据
依赖审计至少覆盖 SwiftPM 与 CocoaPods 的直接、间接依赖。建议把证据分为三层:
| 层级 | 证据 | 解决的问题 |
|---|---|---|
| 声明 | Package.swift、Podfile、Xcode 工程引用 |
团队希望引入什么 |
| 解析 | Package.resolved、Podfile.lock |
实际固定到什么版本或修订 |
| 构建 | 物料清单、文件哈希、构建日志 | 本次实际使用了什么 |
Package.resolved 与 Podfile.lock 必须进入版本库。不要在 CI 中忽略它们,也不要让流水线自动更新后继续发布。
锁文件不是安全结论。它只是稳定输入;审核、哈希与差异记录才说明这份输入为何可以进入构建。
在 ZoomMini 的独享节点上,工作目录可以持续保留,但审计仍应以仓库内文件为准,不能把某台机器缓存中的现状当作唯一证据。
固定解析目录并做干净解析
先把 SwiftPM 下载目录固定到工作区,避免不同 runner 使用无法比较的全局缓存。对包含 workspace 的项目执行:
set -euo pipefail
ROOT="$(pwd)"
SPM_DIR="$ROOT/.audit/spm"
DERIVED_DIR="$ROOT/.audit/DerivedData"
rm -rf "$SPM_DIR" "$DERIVED_DIR"
mkdir -p "$SPM_DIR" "$DERIVED_DIR"
xcodebuild \
-resolvePackageDependencies \
-workspace App.xcworkspace \
-scheme App \
-clonedSourcePackagesDirPath "$SPM_DIR"
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-derivedDataPath "$DERIVED_DIR" \
-clonedSourcePackagesDirPath "$SPM_DIR" \
build
项目只有 .xcodeproj 时,把 -workspace 换成 -project。解析后立即检查仓库是否被修改:
git diff --exit-code -- '**/Package.resolved' Podfile.lock
这条命令失败时应停止构建。常见原因包括开发者更新依赖后漏交锁文件、不同工具版本重写格式,或依赖约束允许了新的间接版本。
记录工具链边界
审计记录还应包含 xcodebuild -version、swift --version、ruby --version 与依赖管理工具版本。它们不一定进入物料清单,但要随构建日志归档。否则锁文件相同而解析行为不同,很难判断差异来自工具链还是源码。
从锁文件生成物料清单
下面的脚本遍历仓库中的 Package.resolved,兼容常见的顶层 pins 与 object.pins 结构,并记录版本、分支、修订和锁文件哈希:
import glob
import hashlib
import json
import os
items = []
for path in glob.glob("**/Package.resolved", recursive=True):
if "/.audit/" in f"/{path}":
continue
with open(path, "rb") as handle:
raw = handle.read()
data = json.loads(raw)
pins = data.get("pins") or data.get("object", {}).get("pins", [])
packages = []
for pin in pins:
state = pin.get("state", {})
packages.append({
"identity": pin.get("identity") or pin.get("package"),
"location": pin.get("location") or pin.get("repositoryURL"),
"version": state.get("version"),
"branch": state.get("branch"),
"revision": state.get("revision")
})
items.append({
"file": path,
"sha256": hashlib.sha256(raw).hexdigest(),
"packages": sorted(packages, key=lambda item: item["identity"] or "")
})
for path in glob.glob("**/Podfile.lock", recursive=True):
with open(path, "rb") as handle:
raw = handle.read()
items.append({
"file": path,
"sha256": hashlib.sha256(raw).hexdigest()
})
os.makedirs(".audit", exist_ok=True)
with open(".audit/dependency-manifest.json", "w") as handle:
json.dump({"artifacts": items}, handle, indent=2, sort_keys=True)
执行后把 dependency-manifest.json 作为构建产物保存。若团队决定将它提交到仓库,就必须在依赖更新提交中同步更新,不能由发布任务悄悄覆盖。
把差异检查变成发布门禁
门禁不应简单禁止所有变化,而应要求变化可解释。一个可执行的顺序是:
- 检查声明文件与锁文件是否同时变更。
- 清空隔离的依赖目录并重新解析。
- 确认解析没有改写锁文件。
- 生成新物料清单并与基线比较。
- 检查新增依赖的许可证、维护来源和脚本执行能力。
- 完成编译与测试后保存清单、锁文件哈希和工具链版本。
可先用标准差异工具形成机器门禁:
python3 tools/dependency_manifest.py
diff -u audit-baseline/dependency-manifest.json .audit/dependency-manifest.json
允许更新时,不要直接关闭门禁。应在独立分支中提交新的基线,并让审核者重点查看新增仓库地址、从版本切换到分支、修订哈希变化,以及没有版本号的依赖。
特别检查脚本阶段
依赖可能通过构建阶段执行 shell 脚本。审核 Xcode 工程与生成的依赖工程时,要确认脚本是否访问网络、读取工作区外的凭据、修改源码目录,或把环境变量写入日志。CI 账户只应获得完成检出、解析和构建所需的最小权限。
异常处理与交付检查项
发现锁文件未变但物料清单哈希变化时,先停止发布,再保留工作区、解析日志与下载目录。随后确认文件是否被重新生成、依赖是否使用可变分支、内部镜像内容是否被替换,以及解析工具版本是否变化。不要先清缓存,否则会丢掉最有价值的现场。
日常交付前可固定检查以下项目:
- 声明文件、锁文件和物料清单属于同一提交。
- SwiftPM 依赖均有明确版本或不可变修订。
- CocoaPods 解析结果与
Podfile.lock一致。 - 新增依赖已经过许可证与脚本阶段审核。
- 干净解析不会产生 Git 差异。
- 构建产物关联了清单哈希与工具链版本。
- CI 中未向依赖脚本暴露无关令牌或私钥。
依赖供应链审计的目标不是制造一份更长的清单,而是建立可重复的判断:输入不变时结果可验证,输入变化时发布会停下,并且审核者能准确看到变化发生在哪里。
常见问题
提交 Package.resolved 和 Podfile.lock 后,还需要生成依赖物料清单吗?
需要。锁文件负责固定解析结果,物料清单则把依赖名称、版本、修订、来源字段和文件哈希整理成统一证据,便于审查、归档与跨构建比较。
依赖门禁应该在完整 Xcode 构建前还是构建后执行?
应先执行锁文件完整性与干净解析检查,再进入完整构建。这样可以更早发现未提交的解析变化,并避免在不可信依赖状态上继续执行脚本阶段。
发现间接依赖修订变化时应该如何处理?
暂停发布,确认上游依赖链、变更原因和许可证,再在独立分支中重新解析与测试。审核通过后同时提交锁文件、物料清单和审查记录。
为构建、测试与实验选择独享物理节点
两档 M4 配置覆盖新加坡、东京、首尔与香港节点。实际可用状态以下单时返回的结果为准。