| name | unity-build |
| description | Build Unity APK/IPA on macOS with Unity 6.x — pre-flight checks, version bump, iOS signing params, post-build verification, buildlog, and commit. |
| when_to_use | Use when building a Unity game, cutting a release, or producing a test build. Triggers: "build the game", "Unity build", "ship Unity build", "make a release", "test build", "/unity-build".
|
| user-invocable | true |
| disable-model-invocation | true |
| allowed-tools | Bash Read Edit Write Glob Grep Agent |
| paths | ["**/Assets/**","**/ProjectSettings/ProjectVersion.txt","**/Packages/manifest.json"] |
/unity-build
Interactive Unity build pipeline: gather params, pre-flight, invoke Unity in
batch mode, archive + export for iOS, verify artifacts, log, commit. Companion
to /flutter-build — same UX + buildlog format.
Threading rule. Run Unity + xcodebuild on the skill's main thread; sub-agents only scaffold and draft the buildlog. Spawn those helpers in the foreground (run_in_background: false, the default) so they return and terminate — never as background teammates. If you ever do background a helper, you MUST shutdown_request it and await shutdown_response once its result is consumed, or it parks as a zombie idle process. (Unlike /flutter-build, this skill has no parallel background builders — Unity can't build two targets on one project at once.)
Step 1: Read Current State
Run in parallel:
- Read
ProjectSettings/ProjectSettings.asset — extract:
productName: → {appname} (used verbatim for artifact filenames — no transformation)
bundleVersion: → {current_version}
AndroidBundleVersionCode: → {current_android_build} (int)
buildNumber: nested map → iPhone: → {current_ios_build} (string; treat as int)
appleDeveloperTeamID: → {team_id} (may be empty; required for iOS)
- Read
ProjectSettings/ProjectVersion.txt — extract m_EditorVersion: → {editor_version}
- Confirm Unity editor binary at
/Applications/Unity/Hub/Editor/{editor_version}/Unity.app/Contents/MacOS/Unity
- Detect Xcode major version (needed for iOS export method default):
xcodebuild -version | head -1 | awk '{print $2}' | cut -d. -f1 → {xcode_major}
- If
xcodebuild is not installed, {xcode_major} is empty — iOS unavailable
- Check if
Assets/Editor/BuildScript.cs exists (agent will scaffold if missing)
- Run
git log --oneline -5 — for context display
- Record
{skill_start_epoch} via date +%s — used for post-build mtime verification
Derive:
next_android_build = current_android_build + 1
next_ios_build = current_ios_build + 1
- Default export method:
{xcode_major} >= 15 → release-testing (renamed from ad-hoc in Xcode 15+)
{xcode_major} <= 14 → ad-hoc
Unity uses separate per-platform build numbers (Android int, iOS string). The skill
keeps them in lockstep by default — user can override.
Fastlane detection (iOS only)
The nextc fastlane setup (generated by setup-ios-signing.sh) supports Unity as a sign-only
path: fastlane lives at the project root (fastlane/Fastfile, Gemfile at root — NOT ios/)
and its lanes sign + export the Builds/iOS/Unity-iPhone.xcodeproj Unity regenerates each build.
test -f fastlane/Fastfile && echo "fastlane" || echo "no-fastlane"
ios_fastlane = true when fastlane/Fastfile exists at the project root, else false. iOS only
(Android always uses the Unity/Gradle path).
- When
true: the fastlane lane replaces the xcodebuild archive/exportArchive/ExportOptions.plist
steps (Step 6) — Unity still generates the Xcode project first, then fastlane signs (match) and runs
gym. See references/fastlane-signing.md. When false: the xcodebuild path is unchanged.
Step 2: Gather Parameters
Show current state + build configuration prompt:
App name : {appname}
Version : {current_version}
Android build #: {current_android_build}
iOS build # : {current_ios_build}
Unity editor : {editor_version}
Xcode : {xcode_major} (iOS only)
Team ID : {team_id or "<not set>"} (iOS only)
Recent commits :
{last 5 commits}
Build configuration:
1. Platform: android / ios / both (default: both)
2. Mode: release / development (default: release)
3. Version: {current_version} (press enter to keep)
4. Android build #: {next_android_build} (press enter to auto-increment)
5. iOS build #: {next_ios_build} (press enter to auto-increment)
If iOS is in platforms, also:
6. iOS export method: {default_method} | app-store-connect | debugging | enterprise
(default: {default_method})
7. Strip Swift symbols: yes / no (default: yes)
8. Compile bitcode: yes / no (default: no)
Please provide choices (e.g., "android, release" or press enter for defaults).
Parse response — use defaults for anything unspecified.
iOS lane selection (only when ios_fastlane = true AND iOS is being built)
When fastlane was detected and iOS is in the platform selection, the fastlane lane controls
signing + export, so questions 6–8 above do not apply (the lane and match own the export
method and signing; no ExportOptions.plist). Ask instead:
This project has fastlane. How should the iOS build be signed + exported?
1. ad-hoc — signed Ad Hoc IPA, installable on registered devices (default)
2. app-store — signed App Store IPA (no upload)
3. testflight — build the App Store IPA AND upload to TestFlight
Record as ios_lane ∈ {ad-hoc, app-store, testflight}; default ad-hoc. The lane always
builds Release internally, so the release/development "Mode" applies to Android only on the
fastlane path. When ios_fastlane = false, skip this question — iOS uses questions 6–8 (xcodebuild).
Step 3: Confirm
Show summary (only include iOS rows if iOS is in platforms):
Build plan:
App name : {appname}
Platforms : {platforms}
Mode : {mode}
Version : {version}
Android # : {android_build}
iOS # : {ios_build}
Unity : {editor_version}
Xcode : {xcode_major}
Team ID : {team_id}
iOS method : {fastlane ({ios_lane}) | xcodebuild} ← only show when iOS is being built
Export : {export_method} ← xcodebuild path only
Swift strip : {strip_swift_symbols} ← xcodebuild path only
Bitcode : {compile_bitcode} ← xcodebuild path only
- `ios_fastlane = true` → `iOS method` = `fastlane ({ios_lane})`, and the Export / Swift strip /
Bitcode rows are omitted (lane + match own signing/export). Otherwise `xcodebuild` + those rows.
Note: Unity may re-serialize scene/meta files when opening. Only
ProjectSettings.asset, BuildScript.cs (+.meta), and docs/buildlog.md are
committed; other changes are reverted.
Proceed?
Wait for confirmation. If user says no, loop to Step 2.
Guardrails before proceeding
- Team ID required for iOS (xcodebuild path only). If iOS is in platforms,
ios_fastlane = false, and {team_id} is empty, STOP: "iOS builds require a Team
ID. Set appleDeveloperTeamID: XXXXXXXXXX (the 10-char identifier from your Apple
developer account) in ProjectSettings/ProjectSettings.asset first." On the fastlane
path this guard is relaxed — the team comes from fastlane/Appfile (team_id) and
match handles signing, so an empty appleDeveloperTeamID is fine.
- Xcode required for iOS. If iOS is in platforms and
{xcode_major} is
empty, STOP: "iOS builds require Xcode. Install Xcode from the App Store
and re-run xcode-select --install." (Applies to both paths — the fastlane
lane runs gym, which drives xcodebuild internally.)
- Fastlane signing config required (fastlane path only). If iOS is in platforms
and
ios_fastlane = true, run the signing preflight from references/fastlane-signing.md:
cfg="${IOS_SIGNING_CONFIG:-$HOME/.fastlane-nextc/config/teams.json}"; if it does not exist,
STOP with the setup-ios-signing.sh <team> <bundle-id> instructions.
Step 4: Pre-Flight Checks
Run BEFORE any build work — each failure aborts:
-
No Unity Editor already running. Run:
pgrep -fl 'Unity\.app/Contents/MacOS/Unity' || true
If any PID is returned, abort: "The Unity Editor appears to be open on this
system. Batch mode will connect to the running editor's license client and
exit 0 without building (silent false success). Close the editor and re-run."
-
No stale lockfile. If Temp/UnityLockfile exists, abort: "Lockfile at
Temp/UnityLockfile — a prior editor session didn't close cleanly. Close
the editor (or rm Temp/UnityLockfile) before re-running."
-
Git status. Run git status --porcelain. If any uncommitted changes
exist, show the list and ask: "You have uncommitted changes. Commit them
first, or proceed (anything Unity re-serializes will be reset, but your
pre-existing changes remain)?" Do NOT proceed without confirmation.
-
Scaffold BuildScript.cs + .meta if missing. If
Assets/Editor/BuildScript.cs does not exist, spawn the unity-builder agent
to generate both the .cs and a matching .meta (deterministic GUID). Stage
both for the Phase 10 commit.
Agent(
subagent_type: "unity-builder",
model: "haiku",
description: "Scaffold BuildScript.cs",
prompt: """
MODE: scaffold
Project root: {project_root}
Write Assets/Editor/BuildScript.cs and Assets/Editor/BuildScript.cs.meta
using your scaffold templates. Do not run any builds. Report the file
paths you wrote.
"""
)
-
Secrets-in-bundle guard (SECURITY). Assets/ (esp. Resources/, StreamingAssets/) packs verbatim into the APK/IPA and is trivially extractable, so scan for secret-bearing files (.env*, secrets.json, *.local.*, service-account*.json, *.pem, *.p12, *.keystore, *.jks — the full runnable find Assets ... command lives in unity-builder Phase F1) and STOP if any match: list the paths, explain they would ship in the binary, and have the user move the secret out of Assets/ before building.
Step 4b: Draft the buildlog "What's new" (before building)
The "What's new" content comes from the git commit range, not from artifacts, so draft and approve it now — before the version bump and the build. A cancel at the review gate then aborts before anything is modified on disk (no slow Unity build wasted), and the approved text is available to feed the iOS testflight lane as FL_CHANGELOG — the upload happens during Step 6, so the notes must exist before it.
Resolve the last tag, then delegate the draft + review to the unity-builder in whats-new mode (foreground — the agent owns date/tag sanity checks, full commit range, --stat reading, vague-subject diff reading, and the Approve / Edit / Cancel gate):
last_tag=$(git describe --tags --abbrev=0 --match 'build/*' 2>/dev/null || echo "")
Agent(
subagent_type: "nextc-unity:unity-builder",
model: "haiku",
prompt: """
Mode: whats-new
Project root: {project_root}
Last build tag: {last_tag or empty}
Version: {version}
Android build: {android_build} ← include when android in platforms
iOS build: {ios_build} ← include when ios in platforms
Platforms: {android|ios|both}
Status: pending
"""
)
The agent returns either:
STATUS: APPROVED with the approved content between ===WHATSNEW_START=== / ===WHATSNEW_END=== delimiters — hold this as approved_whatsnew. Do NOT write it yet; Step 9 assembles and writes the full entry after the build. For the iOS testflight lane, pass this text as FL_CHANGELOG in Step 6 (see references/fastlane-signing.md).
STATUS: CANCELLED — STOP before the version bump. Do NOT bump, build, commit, or tag. Nothing has changed on disk. Report the cancellation; the user can re-run after fixing their concern.
Step 5: Version Bump
Edit ProjectSettings/ProjectSettings.asset in-place via the Edit tool
(never sed — it's structured YAML):
bundleVersion: <old> → bundleVersion: {version}
AndroidBundleVersionCode: <old> → AndroidBundleVersionCode: {android_build}
(only if Android is in platforms)
- Inside
buildNumber: block: iPhone: <old> → iPhone: "{ios_build}"
(only if iOS is in platforms; preserve existing quoting)
Step 6: Build (Unity + xcodebuild)
Ordering
Unity batch mode cannot run concurrently on the same project — both Android
and iOS lock Library/ + Temp/UnityLockfile. The iOS export step (xcodebuild
OR the fastlane lane) does NOT touch Library/ and may parallelize with Unity
Android. For both: Unity iOS (exclusive) → parallel { Unity Android, iOS export
on the generated project } → Phase 7. Single-platform: just the one Unity run,
then the iOS export. Run mkdir -p Builds/logs first.
iOS export branch: when ios_fastlane = true, the iOS export is the fastlane
lane (see "iOS: fastlane lane" below) and the ExportOptions.plist + xcodebuild archive + xcodebuild exportArchive steps are skipped. When false, those
xcodebuild steps run as before. Either way the fastlane lane and xcodebuild run on
the skill's main thread (per the threading rule) — never in a sub-agent.
Android: Unity invocation
"/Applications/Unity/Hub/Editor/{editor_version}/Unity.app/Contents/MacOS/Unity" \
-batchmode -quit -nographics \
-projectPath "{project_root}" \
-buildTarget Android \
-executeMethod BuildScript.BuildAndroid \
-logFile "{project_root}/Builds/logs/android.log" \
-appname "{appname}" \
-buildVersion "{version}" \
-buildNumber "{android_build}" \
{developmentBuildFlag}
{developmentBuildFlag} is -developmentBuild in development mode, empty in release.
The scaffolded BuildScript.cs reads the -appname / -buildVersion / -buildNumber custom args and writes
Builds/Android/{appname}_{version}_{android_build}.apk directly (correct name, no
rename needed) — Step 8 then moves it up to Builds/ root, the standard location.
iOS: Unity invocation (Xcode project gen)
"/Applications/Unity/Hub/Editor/{editor_version}/Unity.app/Contents/MacOS/Unity" \
-batchmode -quit -nographics \
-projectPath "{project_root}" \
-buildTarget iOS \
-executeMethod BuildScript.BuildIOS \
-logFile "{project_root}/Builds/logs/ios-unity.log" \
{developmentBuildFlag}
Expected output: Builds/iOS/Unity-iPhone.xcodeproj (plus the rest of the Xcode
project tree). This is required for both iOS paths — the fastlane lane's
require_export! also expects this project to exist.
iOS: export (fastlane or xcodebuild)
The per-method iOS export command blocks + failure handling live in
references/ios-export.md. Pick the branch by ios_fastlane: true → the fastlane
match+gym lane from the project root (signing/{env_exports} in
references/fastlane-signing.md), skipping xcodebuild; false → the xcodebuild
ExportOptions.plist → archive → exportArchive path.
Step 7: Post-Build Verification
Unity CLI exit 0 is necessary but not sufficient — a no-op run (editor already
open, sandbox-blocked writes) also exits 0. For EACH platform built, assert all
of the following:
- File exists:
[ -f "$ARTIFACT" ]
- Fresh mtime:
[ "$(stat -f %m "$ARTIFACT")" -gt "{skill_start_epoch}" ]
- Reasonable size:
[ "$(stat -f %z "$ARTIFACT")" -gt 10485760 ] (> 10 MiB —
prevents passing on 0-byte placeholders)
Where $ARTIFACT is:
- Android:
Builds/Android/{appname}_{version}_{android_build}.apk
- iOS (xcodebuild path):
Builds/iOS/ipa/<scheme>.ipa (pre-rename)
- iOS (fastlane path): the IPA
gym printed — default project-root Unity-iPhone.ipa (pre-rename).
For the testflight lane the build is also uploaded; still verify the local IPA if one was produced.
If ANY check fails, treat as build failure even if exit code was 0. Surface the
log tail and the check that failed.
Step 8: Artifact Placement (move both to Builds/ root)
After Step 7 verification, move each final artifact to the root of Builds/ (the standard
drop location) with mv (never cp) — one canonical file per platform:
mv "{project_root}/Builds/Android/{appname}_{version}_{android_build}.apk" \
"{project_root}/Builds/{appname}_{version}_{android_build}.apk"
ipa=$(ls -t "{project_root}/Builds/iOS/ipa/"*.ipa "{project_root}/"*.ipa 2>/dev/null | head -1)
mv "$ipa" "{project_root}/Builds/{appname}_{version}_{ios_build}.ipa"
Both finals end at Builds/{appname}_{version}_{build}.{apk,ipa} (Step 11 reports Builds/).
This supersedes the earlier "rename in place, never move" instruction.
Step 9: Build Log (assemble & write)
The "What's new" content was already drafted and approved in Step 4b (before the build). Here you only wrap it in the metadata that exists once the build ran (status, artifact sizes) and write it — do NOT re-draft or re-run the review gate.
Stamp the write timestamp: today=$(date +%Y-%m-%d), time=$(date +%H:%M).
On success: assemble the entry and prepend it below the # Build Log header in docs/buildlog.md (create with the header if missing; newest-first):
## Build — {version} (android {android_build}, ios {ios_build}) ({today} {time})
- **Platforms:** {android, ios, or both}
- **Mode:** {release or development}
- **Unity:** {editor_version}
- **Xcode:** {xcode_version} (iOS only)
- **Signing method:** {export_method or `fastlane match ({lane})`} (iOS only)
- **Artifact sizes:** Android {N} MiB, iOS {N} MiB
- **Status:** success
{approved_whatsnew — the block between the Step 4b delimiters, verbatim}
On failure: the pre-approved "What's new" describes changes that did not ship — do NOT write it. Use the same header + metadata with **Status:** failed and replace the "What's new" block with a one-line error summary.
Post-write lint (both cases):
# Build Log header present
- Entries ordered newest-first (entry dates monotone decreasing top-to-bottom)
- Every entry date ≤ today (catches future-dated bugs —
git checkout -- docs/buildlog.md to revert if any fail, then abort)
- Current entry has all required fields (Platforms / Mode / Unity / Status / a non-empty "What's new" or error line)
Never delete or modify past entries.
Step 10: Commit + Tag
Precondition: the "What's new" was approved in Step 4b and the Step 9 buildlog lint passed. If the user cancelled at the Step 4b review gate, the build never ran; if the Step 9 lint failed, skip Step 10 entirely — no commit, no tag.
10a. Reset Unity re-serialization noise
Unity may have touched scenes/meta/settings that we don't want in this commit.
Reset anything dirty that isn't in the commit whitelist:
git status --porcelain | awk '{print $2}' | while read -r f; do
case "$f" in
ProjectSettings/ProjectSettings.asset) ;;
docs/buildlog.md) ;;
Assets/Editor/BuildScript.cs) ;;
Assets/Editor/BuildScript.cs.meta) ;;
*) git checkout -- "$f" 2>/dev/null || true ;;
esac
done
10b. Stage + commit
git add ProjectSettings/ProjectSettings.asset \
docs/buildlog.md \
Assets/Editor/BuildScript.cs \
Assets/Editor/BuildScript.cs.meta
Commit message:
chore: bump version to {version} (android {android_build}, ios {ios_build})
On single-platform builds, drop the unused side: (android {android_build}) or (ios {ios_build}).
10c. Tag (success only)
git tag build/{version}+{max(android_build, ios_build)}
Do NOT tag partially-failed builds. Do NOT push.
Step 11: Report
Present a table plus diagnostics:
| Platform | Status | Size | Artifact | Path |
|----------|---------|---------|---------------------------------------------|-------------------|
| Android | success | {N MiB} | {appname}_{version}_{android_build}.apk | Builds/ |
| iOS | success | {N MiB} | {appname}_{version}_{ios_build}.ipa | Builds/ |
- Path column = directory only (clickable in file explorer); both artifacts are moved to
Builds/ root in Step 8
- Artifact column = renamed filename
- Paths relative to project root
- On failure:
failed status, one-line error, and the log path
Then append:
Signing : {export_method} (Team {team_id}) ← iOS only, xcodebuild path
Signing : fastlane match ({ios_lane}) ← iOS only, fastlane path; add "→ uploaded to TestFlight" for the testflight lane
Logs : {project_root}/Builds/logs/{android,ios-unity,ios-archive,ios-export}.log ← xcodebuild path; fastlane prints its own log to the console
Phase timings : pre-flight {N}s / Unity iOS {N}s / Unity Android {N}s (parallel w/ xcodebuild) / xcodebuild {N}s / verify {N}s
Committed : {version} (android {android_build}, ios {ios_build})
Tagged : build/{version}+{max_build}
Reminder : `git push && git push --tags` when ready
Omit iOS-specific rows when only Android was built, and vice versa.
Fallback
If unity-builder is unavailable, inline its content tasks (scaffold templates
for BuildScript.cs/.meta, walk git log directly). Build invocations are
already main-thread.
Rules
- NEVER push to remote — commit and tag locally only
- NEVER modify source beyond version fields in
ProjectSettings.asset and the
scaffolded BuildScript.cs
- NEVER edit
ProjectSettings.asset with sed — use the Edit tool
- NEVER skip the build log — mark status "failed" on failures
- NEVER continue to the next platform if one fails
- NEVER tag partially-failed builds
- NEVER dump raw
git log — always curate
- NEVER run Unity invocations in parallel on the same project
- NEVER guess at Unity reinstalls or license cache clears — if a log says
read only / licensing mutex / permission denied, it's a sandbox signal
- NEVER guess iOS signing identity — if xcodebuild prompts interactively, STOP
- SECURITY: NEVER let secret-bearing files (
.env*, secrets.json, *.local.*, service-account*.json, *.pem, *.p12, *.keystore, *.jks) sit under Assets/ at build time — they get packed into the shipped binary. The Step 4 secrets-in-bundle guard must pass before building
- SECURITY (fastlane path): signing secrets live in
~/.fastlane-nextc/ (the teams.json match_password, the .p8 key) — NEVER read those values into the report, log, or commit, and NEVER set MATCH_PASSWORD yourself. They authenticate signing/upload and never enter the app binary — separate from the Assets/ bundle guard above
- On the fastlane path,
match provides the signing identity non-interactively — the "NEVER guess iOS signing identity" rule still holds: if the lane stalls on an interactive Apple prompt, STOP rather than guessing
- Unity exit code 0 + a fresh, reasonably-sized artifact is the success bar,
not exit code alone
- Unity builds are slow (5–20+ min on cold compile); don't set tight Bash
timeouts — prefer
run_in_background: true with log tailing, or
timeout: 1800000 (30 min) on Bash
- Always use absolute paths when invoking the Unity binary