HireVM Engineering Journal

Debugging SwiftPM Build Tool Plugin Sandboxing on a Cloud Mac

Debugging SwiftPM Build Tool Plugin Sandboxing on a Cloud Mac

The same Swift package may generate code correctly on a developer’s machine, yet fail with Operation not permitted in a clean workspace on a cloud Mac—or complete the build while still compiling an outdated file. This is usually not a performance issue. More often, the build tool plugin depends on undeclared files, writes to the wrong directory, or has defects masked by leftover local artifacts. Rather than repeatedly clearing caches, reconstruct the plugin’s inputs, outputs, and execution boundaries one by one.

First identify which layer is failing

SwiftPM build tool plugins involve three layers: SwiftPM plans the commands, the plugin executable generates the content, and Xcode consumes the generated files. Start by capturing a complete log from the repository root:

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

If the log shows that the plugin tool was never found, verify that it is declared as an executable tool from a package target. If the tool starts and then fails, inspect its read and write paths next. If the build succeeds but the generated content is not updated, focus on the input and output declarations.

Do not treat disabling the sandbox as the first fix. A sandbox denial often identifies exactly where the generator is reading or modifying content outside the build graph. Bypassing the restriction only postpones the problem until the next clean build.

Declare inputs and outputs in the build graph

A robust plugin reads only explicitly declared inputs and writes its results to pluginWorkDirectory. The following structure lets SwiftPM track both the schema and the generated Swift file:

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]
            )
        ]
    }
}

After starting, the generator should create the parent Generated directory itself and write only to the output path it was given. It should not default to writing into the repository’s Sources directory, the desktop, the user’s home directory, or a fixed /Users/... path. Build tool plugins are intended to generate derived files that participate in the current compilation. If a task must modify repository contents, implement it as a command plugin that developers run explicitly rather than silently changing source files during every build.

Identify implicit dependencies

Common implicit inputs include configuration files in the current working directory, templates referenced by environment variables, caches in the home directory, and entire folders discovered through automatic scanning. Answer these four questions for each category:

Check Correct approach Warning sign
Input files List all of them in inputFiles Recursively scanning undeclared directories at runtime
Output files List all of them in outputFiles File names vary by time or machine
Working path Use absolute paths passed as arguments Depending on pwd or the home directory
Generation order Sort items before producing stable output Depending on filesystem enumeration order

Keep the generator deterministic

Even with correct permissions, a generator can continuously invalidate incremental builds. The most common causes are writing the current time, a temporary directory, or the host name into the file header. If identical inputs produce different bytes on every run, SwiftPM cannot determine whether a real change occurred.

Run two clean generations in succession on the cloud Mac and compare their digests:

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

The two digests must match. The generator should also sort fields, files, and declarations, normalize line endings, and replace the destination file only when its contents have changed. It can first write a temporary file, compare it with the destination, and then move it atomically. This prevents an interrupted build from leaving behind a partial Swift file.

Reproduce cache problems in a clean workspace

A successful local build does not prove that every dependency has been declared. Local DerivedData, old plugin outputs, or source files generated manually in the past can temporarily hide missing declarations. A cloud build should therefore be validated at least once with no prior state, followed by a second incremental build.

The two-pass validation method

For the first pass, delete the dedicated build directory and run the build, confirming that every generated file is produced by the plugin. For the second pass, build again without changing any inputs and verify that the plugin does not rewrite its outputs unnecessarily. Then change a single schema field and run a third pass to confirm that the corresponding generated file is actually updated.

Use the following checks to quickly detect generated files written to the wrong location:

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

Under normal conditions, derived files should be located in the build directory, and git status should not report version-controlled source files automatically modified by the plugin. If the plugin still runs again during the second pass, verify that the output actually exists, check whether the tool forcibly refreshes timestamps, and determine whether an input directory was declared too broadly.

Define validation boundaries for cloud Macs

In HireVM’s remote build environment or on other clean macOS nodes, add the following checks to the pipeline instead of relying on manual observation:

  1. Pin the selected Xcode installation, and record xcodebuild -version and swift --version at the start of the log.
  2. Give each workspace its own DerivedData and package checkout directories to prevent parallel jobs from writing into one another’s paths.
  3. Start the first build from an empty directory to verify that the plugin does not depend on historical files in the home directory.
  4. Preserve detailed build logs from before and after plugin failures, but do not log tokens, signing materials, or complete environment variables.
  5. Compare digests for critical generated files to confirm that the same commit can be generated reproducibly.
  6. Change one input and rerun the build to verify that the incremental build updates only the expected outputs.
  7. Confirm the currently available configurations in the console and select resources according to the number of concurrent builds. Plugin correctness checks must not depend on the accidental state of one particular machine.

The goal is not to make one build “happen to pass.” The goal is for SwiftPM to fully understand the generation step: rebuild precisely when inputs change, remain quiet when they do not, and produce the same artifacts in a new cloud Mac workspace. Once that is achieved, the sandbox is no longer an obstacle. It becomes an automated check that the build boundaries are genuine and complete.

Frequently asked questions

Should a SwiftPM build tool plugin modify the source directory?

No. Generated files should be written to the plugin work directory and listed in outputFiles. Repository-changing tasks belong in a command plugin that a developer runs explicitly.

Why does the plugin pass locally but fail on a cloud Mac?

Stale local outputs may hide an undeclared file, while the generator may depend on the home directory, current directory, or an absolute path. A clean checkout with fixed build paths exposes those dependencies.

Is disabling sandboxing a valid long-term fix?

No. The durable fix is to declare every input and output, write only to authorized directories, and ensure identical inputs produce byte-identical generated files.

Dedicated Apple Silicon physical nodes

Run your next Remote Mac task on a dedicated physical node

Choose from two available configurations, five nodes, and daily, weekly, monthly, or quarterly terms. Review the full configuration and USD amount before ordering.

Choose a configuration and order