HireVM 工程记录

云端 Mac 的 SwiftPM 构建工具插件沙箱排查实战

云端 Mac 的 SwiftPM 构建工具插件沙箱排查实战

同一个 Swift 包在开发者电脑上生成代码正常,放到云端 Mac 的干净工作区却报 Operation not permitted,或者构建成功但编译到的仍是旧文件。这通常不是机器性能问题,而是构建工具插件依赖了未声明的文件、错误的写入目录,或被本地残留产物掩盖。排查重点不是反复清缓存,而是把插件的输入、输出和执行边界逐项还原。

先确认失败发生在哪一层

SwiftPM 构建工具插件涉及三层:SwiftPM 负责规划命令,插件可执行文件负责生成内容,Xcode 再消费生成文件。先在仓库根目录保留完整日志:

set -o pipefail
rm -rf .ci
mkdir -p .ci
xcodebuild \
  -scheme App \
  -destination 'generic/platform=iOS Simulator' \
  -derivedDataPath "$PWD/.ci/DerivedData" \
  -clonedSourcePackagesDirPath "$PWD/.ci/SourcePackages" \
  build 2>&1 | tee .ci/build.log

grep -E 'sandbox|deny|plugin|Operation not permitted|No such file' .ci/build.log

如果日志显示插件工具根本没有找到,检查它是否声明为 package target 的 executable tool;如果工具已启动后才失败,就继续检查读写路径。若构建成功但生成内容未更新,则重点核对输入和输出声明。

不要先把关闭沙箱当成修复。沙箱拒绝往往准确指出了生成器正在读取或改写构建图之外的内容,绕过限制只会把问题推迟到下一次干净构建。

把输入输出写进构建图

一个稳妥的插件只读取显式输入,并把结果写进 pluginWorkDirectory。下面的结构把 schema 和生成的 Swift 文件都交给 SwiftPM 跟踪:

import PackagePlugin

@main
struct CodegenPlugin: BuildToolPlugin {
    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) async throws -> [Command] {
        let tool = try context.tool(named: "schema-gen")
        let input = target.directory.appending("Schemas/api.json")
        let output = context.pluginWorkDirectory
            .appending("Generated/API.swift")

        return [
            .buildCommand(
                displayName: "Generate API.swift",
                executable: tool.path,
                arguments: [input.string, output.string],
                inputFiles: [input],
                outputFiles: [output]
            )
        ]
    }
}

生成器启动后应自行创建 Generated 父目录,并只写传入的输出路径。不要默认写到仓库的 Sources、桌面、用户主目录或固定的 /Users/...。构建工具插件用于生成参与本次编译的派生文件;如果任务必须修改仓库内容,应改为由开发者明确执行的命令插件,而不是在每次构建时偷偷改源码。

识别隐式依赖

常见隐式输入包括当前工作目录下的配置文件、环境变量指向的模板、主目录中的缓存,以及生成器自动扫描到的整个文件夹。逐项回答下面四个问题:

检查项 正确做法 风险信号
输入文件 全部列入 inputFiles 运行时递归扫描未声明目录
输出文件 全部列入 outputFiles 文件名随时间或机器变化
工作路径 使用参数传入的绝对路径 依赖 pwd 或主目录
生成顺序 排序后稳定输出 依赖文件系统枚举顺序

让生成器本身保持确定性

即使权限正确,生成器也可能让增量构建持续失效。最常见的原因是在文件头写入当前时间、临时目录或主机名。相同输入每次都产生不同字节,SwiftPM 就无法判断变化是否真实。

在云端 Mac 上连续执行两次干净生成,并比较摘要:

rm -rf .ci/first .ci/second
mkdir -p .ci/first .ci/second

.build/debug/schema-gen \
  Schemas/api.json .ci/first/API.swift

.build/debug/schema-gen \
  Schemas/api.json .ci/second/API.swift

shasum -a 256 .ci/first/API.swift .ci/second/API.swift
cmp .ci/first/API.swift .ci/second/API.swift

两个摘要必须一致。生成器还应对字段、文件和声明排序,统一换行符,仅在内容变化时替换目标文件。可以先写临时文件,比较后再原子移动,避免构建中断时留下半个 Swift 文件。

用干净工作区复现缓存问题

本地成功不代表声明完整。本地 DerivedData、旧的插件输出或曾经手工生成的源码,都会让漏声明暂时不可见。云端构建应至少执行一次无历史状态的验收,然后再执行第二次增量构建。

两轮验收法

第一轮删除专用构建目录并构建,确认所有生成文件都由插件产生。第二轮不修改输入再次构建,确认插件不会无故重写输出。随后只改一个 schema 字段再执行第三轮,确认对应生成文件确实更新。

可用下面的检查快速发现生成文件跑错位置:

find "$PWD" -type f -name 'API.swift' -print
git status --short

正常情况下,派生文件位于构建目录,git status 不应出现插件自动改写的受版本控制源码。若第二轮仍重复执行,检查输出是否真实存在、时间戳是否被工具强制刷新,以及某个输入目录是否声明得过宽。

固化云端 Mac 的验收边界

在 HireVM 的远程构建环境或其他干净 macOS 节点上,建议把以下检查写入流水线,而不是依赖人工观察:

  1. 固定 Xcode 选择结果,并在日志开头记录 xcodebuild -versionswift --version
  2. 为每个工作区设置独立的 DerivedData 和包检出目录,避免并行任务交叉写入。
  3. 首次构建从空目录开始,验证插件没有依赖主目录中的历史文件。
  4. 保存插件失败前后的详细构建日志,但不要把令牌、签名材料或完整环境变量写入日志。
  5. 对关键生成文件执行摘要比较,确认同一提交可重复生成。
  6. 修改单个输入后复跑,确认增量构建只更新预期输出。
  7. 在控制台确认当前可选配置,并依据并发构建数量选择资源;插件正确性检查不应依赖某一台机器的偶然状态。

最终目标不是让一次构建“碰巧通过”,而是让 SwiftPM 能完整理解生成步骤:输入变化时准确重建,输入不变时保持安静,换到新的云端 Mac 工作区仍得到相同产物。做到这一点后,沙箱不再是阻碍,而是验证构建边界是否真实、完整的一道自动检查。

常见问题

SwiftPM 构建工具插件可以直接修改源码目录吗?

不应这样设计。构建工具插件应把生成文件写入插件工作目录,并通过 outputFiles 明确声明;需要修改仓库内容的操作应拆成由开发者主动执行的命令插件。

为什么插件在本地成功,在云端 Mac CI 中却被沙箱拒绝?

常见原因是本地残留文件掩盖了未声明输出,或生成器依赖当前目录、用户主目录和绝对路径。使用干净检出、固定构建目录并检查详细日志,通常可以暴露真实依赖。

能否通过关闭沙箱快速解决插件失败?

不建议把关闭沙箱作为长期方案。正确做法是声明所有输入输出、只向授权目录写入,并让生成器在相同输入下产生字节一致的结果。

独享 Apple Silicon 物理节点

把下一项远程 Mac 任务放到独享节点运行

按任务规模选择两档在售配置、五个节点与日、周、月、季周期。下单前可完整核对配置和 USD 金额。

选择配置并下单