云端 Mac 上的工程从本地构建正常迁到无人值守任务后,最容易被忽略的并不是编译器参数,而是 Build Phases 里的 Run Script。一个脚本可能读取仓库外的配置、写入源码目录,或者因为没有输出声明而在每次构建时执行。单次运行看不出问题,并发任务却会互相覆盖文件,增量构建也会逐渐失去意义。处理这类故障,应先把每个脚本的文件边界写清楚,再开启沙箱验证,而不是先增加重试。
先建立可复现的审计基线
先固定工程、Scheme、构建配置和 Derived Data 目录。审计期间不要复用日常目录,否则旧产物可能让缺失的生成步骤看起来仍然正常。
set -euo pipefail
ROOT="$PWD"
DERIVED="$ROOT/.audit-derived"
rm -rf "$DERIVED"
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-derivedDataPath "$DERIVED" \
clean build | tee "$ROOT/audit-clean.log"
如果工程使用 Workspace,就把 -project 换成 -workspace。首次构建用于确认完整链路,随后保持源码不变,再执行一次不带 clean 的构建。记录 Run Script 的执行次数、总耗时和输出文件修改时间。这两份日志分别代表干净构建与增量构建基线。
审计目标不是让所有脚本都跳过,而是让每个脚本只在输入变化、输出缺失或执行条件明确要求时运行。
盘点所有 Run Script 阶段
先从工程文件定位脚本,再回到 Xcode 检查它属于哪个 Target、位于编译前还是编译后,以及是否启用了依赖分析。文本搜索适合做初筛:
grep -nE "PBXShellScriptBuildPhase|shellScript =|inputPaths =|outputPaths =" \
App.xcodeproj/project.pbxproj
为每个阶段建立表格,不要只记录脚本名称。名称经常是“Run Script”,无法用于排障。
| 检查项 | 要回答的问题 |
|---|---|
| 执行条件 | 每次运行,还是仅在依赖变化时运行 |
| 输入 | 读取哪些源码、配置、工具和文件列表 |
| 输出 | 生成文件、报告或完成标记写到哪里 |
| 副作用 | 是否修改源码、全局配置或共享缓存 |
| 并发性 | 两个任务同时执行时是否写同一路径 |
| 失败策略 | 子命令失败后是否立即返回非零状态 |
脚本开头建议使用 set -euo pipefail。同时检查被管道连接的命令,因为没有 pipefail 时,前一条命令失败可能被末尾成功的 tee 掩盖。
用 xcfilelist 声明文件边界
少量路径可以直接填入 Input Files 和 Output Files;文件较多时,用 .xcfilelist 更容易审查。路径应尽量基于 $(SRCROOT)、$(DERIVED_FILE_DIR) 等构建变量,避免写死用户目录。
例如,一个根据 YAML 配置生成摘要的阶段,可以使用以下输入列表:
$(SRCROOT)/Config/app.yml
$(SRCROOT)/Scripts/generate-config.sh
输出列表只声明脚本真正产生的文件:
$(DERIVED_FILE_DIR)/Generated/config.sha256
对应脚本应将临时文件先写到目标目录,再原子替换最终文件,避免并发读取半成品:
set -euo pipefail
SOURCE="$SRCROOT/Config/app.yml"
OUTPUT="$DERIVED_FILE_DIR/Generated/config.sha256"
TEMP="$OUTPUT.tmp.$$"
mkdir -p "$(dirname "$OUTPUT")"
shasum -a 256 "$SOURCE" > "$TEMP"
mv "$TEMP" "$OUTPUT"
不要把整个仓库目录粗略声明成输入,也不要把源码根目录当输出。范围过宽虽然可能消除报错,却会让任意文件变化都触发脚本,并掩盖真实依赖。生成物优先写入 Derived Data;确需写回仓库的代码生成步骤,应单独执行并由版本控制检查差异。
开启沙箱并读取拒绝日志
完成第一轮声明后,通过命令行覆盖构建设置进行验收,不必一开始就修改所有配置:
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-derivedDataPath "$PWD/.audit-derived" \
ENABLE_USER_SCRIPT_SANDBOXING=YES \
build | tee "$PWD/audit-sandbox.log"
若出现 sandbox deny,先定位被拒绝路径、操作类型和对应阶段。读取配置却未声明,应补到输入;创建或修改文件未声明,应补到输出。工具自身读取系统运行库通常不需要把系统目录整体加入列表,重点检查脚本显式访问的工程文件、配置文件和自建工具路径。
常见误区是直接关闭沙箱,或把用户主目录加入输入范围。前者让隐式依赖继续存在,后者会造成不可控的重建。脚本若依赖仓库外文件,应把该文件复制到任务工作目录,经过校验后再作为明确输入。
把增量与并发验收纳入 CI
修正后至少执行四组测试:空 Derived Data 的干净构建、源码不变的第二次构建、修改单个声明输入后的构建,以及两个独立 Derived Data 目录的并行构建。并行任务应共享源码只读副本,不能共享输出目录。
验收时按以下顺序检查:
- 干净构建可以从零生成全部必要产物。
- 第二次构建不会无条件执行已有稳定输出的脚本。
- 修改声明输入后,对应阶段会重新运行。
- 修改无关文件不会触发该阶段。
- 两个并行任务不会覆盖同一临时文件或报告。
- 脚本失败会让
xcodebuild返回非零状态。 - 日志不输出令牌、私钥内容或完整环境变量。
最后把沙箱设置写入团队实际使用的构建配置,并保留一条定期执行的干净构建任务。增量构建负责速度,干净构建负责发现遗漏依赖;两者同时通过,Run Script 才算具备可重复执行的边界。
常见问题
为什么 Run Script 每次构建都会执行?
常见原因是没有声明输出文件,或关闭了基于依赖分析的执行条件。为脚本补齐稳定的输入与输出路径后,Xcode 才能判断是否需要重新运行。
启用脚本沙箱后出现 deny 日志应该怎么处理?
先从构建日志确认被拒绝的路径与读写方向,再把必要路径加入 Input Files、Input File Lists、Output Files 或 Output File Lists;不要用关闭沙箱掩盖未声明依赖。
如何确认脚本改造没有破坏增量构建?
连续执行两次相同构建,第二次不应无条件重跑脚本;随后修改一个已声明输入,脚本应执行且输出更新时间发生变化,未改输入时输出应保持稳定。
把下一次 iOS 构建放到 MiniDebug M4 上。
固定提供 M4、16GB RAM 与 256GB SSD,可按天、周、月或季租用,并从新加坡、日本东京、韩国首尔、香港、美国东部五个节点中选择。实际可用状态以控制台实时返回为准。