A Unity project may export to iOS successfully on a developer machine, then stall during asset import, native dependency setup, or linking after moving to a cloud Mac. The hardest part is not the failure itself, but having every step packed into a single command. When the log says only “build failed,” there is no reliable way to tell whether to rerun Unity, clear a cache, or inspect the Xcode project. A more robust pipeline uses two independent gates: first generate an auditable Xcode project, then validate native compilation without performing any signing operations.
Define Input and Artifact Boundaries First
A reproducible build must pin at least four categories of input: the commit revision, Unity Editor version, package dependency lock file, and scene list. Scripts should not read editor settings from an individual developer’s machine at runtime, and a newly exported Xcode project should not overwrite the previous export and continue building on top of it.
Use a separate directory layout for each job:
| Path | Purpose | Cacheable |
|---|---|---|
Source/ |
Unity project at the current commit | No |
Library/ |
Asset import results | Conditionally |
Build/iOS/ |
Xcode project exported by the current job | No |
.build/DerivedData/ |
Intermediate native build files | Conditionally |
Artifacts/ |
Logs, summaries, and validation results | No |
The cache key for Library should include the Unity version, target platform, and digests of Packages/manifest.json and Packages/packages-lock.json. Reimport assets whenever any of these inputs changes. Likewise, do not reuse DerivedData directly across different Xcode versions or project configurations.
A cache can only accelerate inputs that have already been pinned. It cannot replace version locking. A cache hit with poorly defined boundaries is usually harder to debug than a clean rebuild.
Export the Xcode Project in Batch Mode
Place the export entry point in Assets/Editor/IosExport.cs. Generate the scene list from the enabled entries in Build Settings so the script and the editor UI do not maintain separate lists.
using System;
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
public static class IosExport
{
public static void Run()
{
var scenes = EditorBuildSettings.scenes
.Where(scene => scene.enabled)
.Select(scene => scene.path)
.ToArray();
if (scenes.Length == 0)
throw new InvalidOperationException("No enabled scenes.");
var options = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = "Build/iOS",
target = BuildTarget.iOS,
options = BuildOptions.CleanBuildCache
};
var report = BuildPipeline.BuildPlayer(options);
if (report.summary.result != BuildResult.Succeeded)
throw new InvalidOperationException("Unity export failed.");
}
}
Pass the project directory, log path, and method name explicitly when invoking Unity:
"$UNITY_EDITOR" \
-batchmode \
-nographics \
-quit \
-projectPath "$PWD" \
-executeMethod IosExport.Run \
-logFile "$PWD/Artifacts/unity-export.log"
Delete Build/iOS before the job starts, but do not delete Library unconditionally. When the job finishes, verify the exit code, the log file, and the presence of Build/iOS/Unity-iPhone.xcodeproj. A directory on disk does not prove that the export succeeded; failed jobs can leave partial output behind.
Separate Native Dependencies from Compilation Validation
A successful Unity export only proves that the generator completed its work. It does not prove that Objective-C, Swift, IL2CPP output, and native libraries can compile together. Run the project’s required native dependency installation step first, then determine whether to build the workspace or the project. If a dependency tool has generated a .xcworkspace, continuing to build the .xcodeproj will often result in missing modules or unresolved libraries.
set -euo pipefail
cd Build/iOS
rm -rf ../../.build/DerivedData
if [ -d "Unity-iPhone.xcworkspace" ]; then
xcodebuild \
-workspace Unity-iPhone.xcworkspace \
-scheme Unity-iPhone \
-configuration Release \
-sdk iphoneos \
-derivedDataPath ../../.build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
else
xcodebuild \
-project Unity-iPhone.xcodeproj \
-scheme Unity-iPhone \
-configuration Release \
-sdk iphoneos \
-derivedDataPath ../../.build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
fi
The goal at this stage is to validate compilation and linking, not to produce a distributable package. Signing belongs in a later, controlled stage so routine commit validation jobs do not need access to sensitive material.
Invalidate Caches Deterministically, Not by Guesswork
A common mistake in Unity projects is using only the branch name as the cache key. Two commits on the same branch do not necessarily produce identical asset import results. Start by generating a digest of the relevant inputs:
{
printf '%s
' "$UNITY_VERSION"
printf '%s
' "$XCODE_VERSION"
shasum -a 256 Packages/manifest.json
shasum -a 256 Packages/packages-lock.json
find Assets -name '*.asmdef' -o -name '*.rsp' | sort | xargs shasum -a 256
} | shasum -a 256 | awk '{print $1}' > Artifacts/cache-key.txt
Do Not Cache the Entire Working Directory
Caching the entire project directory mixes stale exports, temporary settings, and files that have since been deleted into the restored workspace. Manage Library and DerivedData separately, and validate the input digest after restoring either cache. If compilation differs for no obvious reason, preserve the failed logs first and rerun once with an empty cache. If the clean-cache run succeeds, classify the issue as a cache-boundary problem rather than immediately changing application code.
Archive the Minimum Evidence Needed for Reproduction
Do not preserve only a success marker after the build passes. At minimum, archive the complete Unity log, Xcode output, commit hash, tool versions, cache key, and a summary of the exported project. The project summary may record the scheme, configuration, and build settings, but it should exclude credentials, tokens, and private local paths.
Common failures can be triaged quickly by stage:
- The Unity log never reaches
IosExport.Run: check the method name, script compilation errors, and Editor version. - The export stage reports an empty scene list: inspect the enabled scenes in Build Settings instead of temporarily hard-coding paths.
- A workspace exists, but the script builds the project: adjust when the check runs and make sure dependency installation has completed.
- IL2CPP or native library linking fails: verify the target platform, plugin architectures, and conditional import settings.
- The same commit succeeds intermittently: disable the cache, rerun the job, and compare the cache keys and tool versions from both runs.
- The build passes locally but fails remotely: compare path capitalization, uncommitted files, and environment variables before blaming machine performance.
Finally, represent “export succeeded” and “native compilation succeeded” as two independent states. If export fails, there is no reason to run Xcode. If native compilation fails, there is no need to reimport every asset repeatedly. This separation makes the pipeline easier to retry, keeps logs shorter, and creates clearer ownership boundaries.
Frequently asked questions
Why not let Unity produce the final package in one step?
Separate stages distinguish Unity scripting and asset-import failures from native dependency, compiler, and linker failures. An unsigned build also validates the project before controlled signing credentials are introduced.
Can the Unity Library directory be shared across projects or editor versions?
It should not be shared blindly. The cache key should include the Unity editor version, dependency lock files, and target platform; rebuild Library whenever any of those inputs changes.
When should the pipeline build an xcworkspace?
Build the workspace when native dependency installation creates one. Use the xcodeproj only when no workspace exists, and perform that detection after dependencies have been prepared.
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.