同一提交在本地通过,放到持续运行的云端 Mac 后却间歇出现 module file was created by a different version of the compiler,重跑有时又恢复。这类故障通常不是 Swift 源码随机变化,而是不同工具链、SDK 或并行任务读到了同一份预编译模块缓存。直接删除全部 DerivedData 虽能暂时止痛,却会抹掉最关键的现场,也让下一次污染继续发生。
先确认故障属于模块缓存
Clang 会把系统头文件与 Objective-C 模块编译成 PCM,Swift 编译也会维护自己的模块缓存。只要缓存生成时使用的编译器、SDK、架构或编译参数与当前任务不一致,加载阶段就可能失败。
先在完整日志中检索这些特征:
| 日志特征 | 更可能的原因 | 首个检查点 |
|---|---|---|
different version of the compiler |
切换过 Xcode | xcodebuild -version |
PCM file is out of date |
SDK 或头文件时间戳变化 | 当前 SDK 路径 |
could not build module |
上游模块损坏 | 首个失败模块 |
| 同一任务重跑后通过 | 并行写入或共享缓存 | 缓存目录归属 |
不要只截取最后一条错误。模块加载失败常会引发几十条级联报错,应向前找到第一个出现 module、.pcm 或 ModuleCache 的位置。
清缓存之前先保存失败日志、Xcode 构建号和实际构建设置。没有这些信息,恢复成功也无法判断是工具链漂移、并发污染,还是缓存本身损坏。
为失败任务保存可比较的现场
在 VMKeep 的云端 Mac 上,先记录命令实际解析到的开发者目录,而不是只看脚本中期望使用的版本。
set -o pipefail
mkdir -p artifacts/diagnostics
xcode-select -p | tee artifacts/diagnostics/developer-dir.txt
xcodebuild -version | tee artifacts/diagnostics/xcode-version.txt
xcrun --sdk iphoneos --show-sdk-path | tee artifacts/diagnostics/sdk-path.txt
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-showBuildSettings \
> artifacts/diagnostics/build-settings.txt
随后检查失败命令里的 SDKROOT、ARCHS、DEVELOPER_DIR、CLANG_MODULE_CACHE_PATH 和 SWIFT_MODULE_CACHE_PATH。若任务通过 sudo、远程会话或调度进程启动,还要比较执行用户与 HOME;同一脚本换了用户,默认缓存位置也会改变。
固定工具链入口
构建脚本应显式设置 DEVELOPER_DIR,并在真正编译前执行一次 xcodebuild -version。版本不符合预期时立即退出,不要让任务带着错误工具链写入共享缓存。升级 Xcode 后应生成新的缓存键,而不是继续沿用旧目录。
把缓存边界缩小到单个任务
可靠的目录键至少包含 Xcode 构建号、SDK、架构和任务标识。分支名不能直接当路径,应先转换特殊字符。下面的做法让每个任务拥有独立 DerivedData 与模块缓存,同时把依赖下载目录留在单独位置。
set -euo pipefail
JOB_KEY="$(printf '%s' "${CI_JOB_ID:-local}" | tr -cs 'A-Za-z0-9._-' '-')"
XCODE_BUILD="$(xcodebuild -version | awk '/Build version/{print $3}')"
SDK_BUILD="$(xcrun --sdk iphoneos --show-sdk-build-version)"
CACHE_KEY="${XCODE_BUILD}-${SDK_BUILD}-arm64-${JOB_KEY}"
ROOT="$HOME/BuildCaches/$CACHE_KEY"
DERIVED="$ROOT/DerivedData"
MODULES="$ROOT/Modules"
PACKAGES="$HOME/BuildCaches/SourcePackages"
mkdir -p "$DERIVED" "$MODULES/clang" "$MODULES/swift" "$PACKAGES"
xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath "$DERIVED" \
-clonedSourcePackagesDirPath "$PACKAGES" \
CLANG_MODULE_CACHE_PATH="$MODULES/clang" \
SWIFT_MODULE_CACHE_PATH="$MODULES/swift" \
build
长期分支可以复用自己的稳定键,临时合并请求则应按任务隔离。不要让两个仍在运行的 xcodebuild 进程写同一个模块目录,也不要把模块缓存作为可跨工具链恢复的通用压缩包。
精准清理而不是一键归零
确认是模块缓存故障后,先停止使用该目录的构建进程,再按范围从小到大处理:
- 删除当前任务的
Modules/clang与Modules/swift。 - 保留
SourcePackages,重新执行依赖解析与构建。 - 若索引或中间产物仍引用旧模块,再删除当前任务的 DerivedData。
- 若只有一个第三方模块失败,核对其二进制产物支持的架构与当前 SDK,不要反复清缓存掩盖兼容性问题。
清理动作必须限定在计算出的任务目录。使用 rm -rf ~/Library/Developer/Xcode/DerivedData/* 会同时影响其他任务,容易把一次局部故障扩大成整台机器的冷启动。
防止误删正在使用的目录
回收旧缓存时,以任务锁或运行记录确认目录无人占用,再按最后使用时间删除。不要只依据目录创建时间,因为稳定分支可能持续复用较早建立的缓存。清理脚本还应验证目标路径位于预期根目录下,空变量或路径计算失败时直接退出。
把缓存一致性变成构建门禁
最终应让流水线主动暴露漂移。每次构建保存 Xcode 构建号、SDK 构建号、架构、缓存键和执行用户;任务结束后将失败日志与这些信息一起归档。升级工具链时创建新命名空间,确认新链路稳定后再回收旧缓存。
可以再安排一个不恢复模块缓存的验收任务。若常规任务失败而干净任务通过,问题集中在缓存层;若两者都失败,应回到源码、依赖或构建设置继续排查。这样既避免把所有错误归因于缓存,也不必靠重复执行碰运气。
模块缓存本质上是编译器输入的一部分。把版本、目录所有权和并发边界写进脚本后,PCM 失效就会从随机故障变成可定位、可恢复的工程事件。
常见问题
遇到 PCM 失效时需要删除整个 DerivedData 吗?
不需要先做全量删除。应先保存日志和构建设置,再只清理当前任务的 ModuleCache.noindex;只有索引、产物和模块缓存同时异常时,才重建该任务独占的 DerivedData。
不同 Xcode 版本可以共用同一个模块缓存目录吗?
不建议。模块缓存键至少应包含 Xcode 构建号、SDK、架构和任务标识,否则编译器升级或任务并行时容易复用不兼容的 PCM 文件。
在云端 Mac 上继续验证这套工作流
从三档 Apple Silicon 配置中选择合适的内存、存储、节点与租用周期。