When a project that builds correctly on a local Mac is moved to unattended cloud Mac jobs, compiler flags are rarely the most overlooked issue. The bigger risk is usually a Run Script in Build Phases. A script may read configuration from outside the repository, write into the source tree, or run during every build because it declares no outputs. These problems can remain invisible in a single run, yet concurrent jobs may overwrite one another’s files and incremental builds can gradually lose their value. The right approach is to define each script’s file boundaries first and then validate them with sandboxing—not to begin by adding retries.
Establish a reproducible audit baseline first
Pin the project, Scheme, build configuration, and Derived Data directory before starting. Do not reuse a routine development directory during the audit, because stale artifacts can make a missing generation step appear to work correctly.
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"
If the project uses a Workspace, replace -project with -workspace. Use the first build to verify the complete pipeline. Then leave the source unchanged and run another build without clean. Record how many times each Run Script executes, its total duration, and the modification times of its output files. These two logs establish the clean-build and incremental-build baselines.
The goal of the audit is not to skip every script. Each script should run only when an input changes, an output is missing, or an explicit execution condition requires it.
Inventory every Run Script phase
Start by locating scripts in the project file. Then inspect each one in Xcode to determine which Target it belongs to, whether it runs before or after compilation, and whether dependency analysis is enabled. Text search is useful for the initial pass:
grep -nE "PBXShellScriptBuildPhase|shellScript =|inputPaths =|outputPaths =" \
App.xcodeproj/project.pbxproj
Create a table for every phase rather than recording only its name. Many phases are simply named “Run Script,” which is not enough for troubleshooting.
| Check | Question to answer |
|---|---|
| Execution condition | Does it run every time or only when dependencies change? |
| Inputs | Which source files, configurations, tools, and file lists does it read? |
| Outputs | Where does it write generated files, reports, or completion markers? |
| Side effects | Does it modify source files, global configuration, or shared caches? |
| Concurrency | Could two jobs write to the same path at the same time? |
| Failure handling | Does a failed subcommand immediately produce a nonzero exit status? |
Scripts should generally begin with set -euo pipefail. Review commands connected by pipelines as well: without pipefail, a successful tee at the end can hide the failure of an earlier command.
Declare file boundaries with xcfilelist
A small number of paths can be entered directly under Input Files and Output Files. For larger sets, .xcfilelist files are easier to review. Base paths on build variables such as $(SRCROOT) and $(DERIVED_FILE_DIR) whenever possible instead of hard-coding a user directory.
For example, a phase that generates a digest from a YAML configuration could use this input list:
$(SRCROOT)/Config/app.yml
$(SRCROOT)/Scripts/generate-config.sh
The output list should declare only the file the script actually creates:
$(DERIVED_FILE_DIR)/Generated/config.sha256
The corresponding script should write a temporary file in the destination directory and then atomically replace the final file. This prevents concurrent readers from seeing a partially written artifact:
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"
Do not broadly declare the entire repository as an input, and do not use the source root as an output. An overly broad scope may eliminate errors, but it also causes arbitrary file changes to rerun the script and conceals the actual dependencies. Generated artifacts should preferably be written to Derived Data. If a code-generation step genuinely needs to write back to the repository, run it separately and use version control to verify the resulting changes.
Enable sandboxing and inspect denial logs
After completing the first round of declarations, validate them by overriding the build setting on the command line. There is no need to modify every configuration at the outset:
xcodebuild \
-project App.xcodeproj \
-scheme App \
-configuration Debug \
-derivedDataPath "$PWD/.audit-derived" \
ENABLE_USER_SCRIPT_SANDBOXING=YES \
build | tee "$PWD/audit-sandbox.log"
If a sandbox deny occurs, first identify the denied path, the operation, and the responsible phase. A configuration file that is read but not declared belongs in the inputs. A file that is created or modified but not declared belongs in the outputs. Tools commonly read system runtime libraries without requiring entire system directories to be added to the lists. Focus on project files, configuration files, and custom tool paths that the script accesses explicitly.
Two common mistakes are disabling the sandbox outright and adding the user’s home directory to the input scope. The first leaves hidden dependencies in place; the second causes unpredictable rebuilds. If a script depends on a file outside the repository, copy that file into the job’s working directory, validate it, and then declare the copy as an explicit input.
Add incremental and concurrent validation to CI
After making corrections, run at least four groups of tests: a clean build with an empty Derived Data directory, a second build with unchanged source, a build after modifying one declared input, and parallel builds using two independent Derived Data directories. Parallel jobs may share a read-only copy of the source, but they must not share output directories.
Validate the results in this order:
- A clean build can generate every required artifact from scratch.
- The second build does not unconditionally run scripts whose stable outputs already exist.
- Changing a declared input reruns the corresponding phase.
- Changing an unrelated file does not trigger that phase.
- Two parallel jobs do not overwrite the same temporary file or report.
- A script failure causes
xcodebuildto return a nonzero status. - Logs do not print tokens, private-key contents, or the complete environment.
Finally, enable sandboxing in the build configurations the team actually uses and retain a scheduled clean-build job. Incremental builds provide speed, while clean builds expose missing dependencies. A Run Script has reproducible boundaries only when both continue to pass.
Frequently asked questions
Why does an Xcode Run Script execute on every build?
The phase usually has no declared output or dependency-based execution is disabled. Stable input and output declarations let Xcode decide whether the phase is already up to date.
How should I handle a sandbox deny message?
Identify the denied path and operation in the build log, then declare the required path as an input or output, directly or through an xcfilelist. Do not hide the dependency by disabling sandboxing.
How can I verify incremental-build behavior?
Run the same build twice, then modify one declared input. The unchanged second run should skip the phase, while the input change should rerun it and refresh its output.
Move your next iOS build to MiniDebug M4.
M4, 16GB RAM, and a 256GB SSD are included. Rent by the day, week, month, or quarter, and choose from five locations: Singapore, Tokyo, Seoul, Hong Kong, or the US East Coast. Actual availability is shown in real time by the console.