51 KiB
Run, test & build — unity-cli command reference
Part of the unity-cli skill. See that skill's SKILL.md for CLI install, global flags,
environment variables, exit codes, and common workflows. All global flags (--format json,
--non-interactive, --yes, --proxy, …) apply to every command below.
Run — batch/headless execution
# Run a Unity project headless (batch mode is automatic — do NOT pass -batchmode/-quit)
unity run /path/to/MyProject -- -executeMethod Builder.Build
# Override editor version
unity run /path/to/MyProject --editor-version 6000.0.47f1 -- -nographics -logFile out.log
# Install editor automatically if missing
unity run /path/to/MyProject --allow-install -- -executeMethod Builder.Build
# Kill the Unity process after 300 seconds (useful in CI to prevent hangs)
unity run /path/to/MyProject --timeout 300 -- -executeMethod Builder.Build
# Equivalent via env var:
UNITY_RUN_TIMEOUT=300 unity run /path/to/MyProject -- -executeMethod Builder.Build
# Write the Editor log to a file (also streams to the console by default)
unity run /path/to/MyProject --log-file ./run.log -- -executeMethod Builder.Build
unity run /path/to/MyProject -l ./run.log --no-tail -- -executeMethod Builder.Build
unity run always launches the editor in batch mode and forwards the args after -- to the Unity executable, then returns the editor's exit code.
Reserved flags — do NOT pass these after --. The command manages -batchmode, -quit, and -projectPath itself, and deliberately never passes -useHub/-hubIPC (the CLI runs no Hub IPC server, so those flags would make the editor launch the Unity Hub). Passing any of the five fails fast (before launch) with exit code 6:
Error: Forwarded argument '-batchmode' conflicts with a reserved Unity flag managed by this command. Remove it from the args after `--`.
Flags like -nographics, -logFile <path>, and -executeMethod <Class.Method> are not reserved and are forwarded normally.
Reserved-flag matching is spelling-insensitive: Unity accepts -projectPath, --projectPath and -projectPath=<value> interchangeably, so all three spellings are rejected (case-insensitively). This applies to every command that forwards user args — unity run, unity test, unity build --args, and unity open --args.
When --timeout <seconds> is set, the process receives SIGTERM at the deadline; if still alive after 2 s it receives SIGKILL. The command exits with code 6 (EXIT_COMMAND_FAILURE) on timeout.
-l, --log-file <path> writes the Editor log to that path instead of Unity's default log location — the same idea unity build already has, now first-class on run too. Forwarding -- -logFile <path> by hand still works and is unaffected when --log-file is not passed, but -logFile becomes a reserved flag (rejected, same as -batchmode) the moment --log-file supplies a path, so the two spellings can't fight over Unity's last-wins parser. The log also streams to the console while the run happens (stdout in human format, stderr otherwise); --no-tail writes the file only.
run --command — execute a registered Editor command headlessly
unity run --command <name> runs a registered [CliCommand] Editor command in a single invocation: the CLI starts the Editor in batch mode, waits for the project's Pipeline server, runs the command with the arguments after -- parsed against the command's [CliArg] schema (no hand-written Environment.GetCommandLineArgs() parsing), prints the return value, and shuts the Editor down. A running Editor with the project already open is reused (and left running) instead of spawning a second one. Requires the com.unity.pipeline package (unity pipeline install — see integration-advanced.md).
# Run a registered command; arguments after -- are parsed against its [CliArg] schema
unity run /path/to/MyProject --command my_command -- --count 3 --label demo
# JSON result envelope (data carries the return value); bound the wait
unity run /path/to/MyProject --command my_command --format json --timeout 120
# Write the Editor log to a file — there was no route to this at all before
# --log-file, since args after -- are the command's own and never reach Unity
unity run /path/to/MyProject --command my_command --log-file ./run.log -- --name Ada
--log-file (with -l short form) and --no-tail work the same way here as in plain mode: the log streams to the CLI's own stderr by default (this mode already reserves stdout for the command result), and --no-tail writes the file only. It only takes effect when this invocation spawns the Editor. If the project is already open in an Editor this command didn't start, the log destination was fixed at that Editor's launch and the CLI warns instead of leaving you watching a path nothing will write; under --format json/ndjson (where warnings don't render) the result envelope carries a logFileApplied boolean instead — omitted entirely when --log-file was never requested, true/false otherwise.
Worked example. Given this command in the project (authoring details in integration-advanced.md):
public static class MyPipelineCommands
{
[CliCommand("greet", "Log a greeting and return its length")]
public static int Greet(
[CliArg("name", "Who to greet", Required = true)] string name)
{
Debug.Log($"Hello, {name}!");
return name.Length;
}
}
unity run . --command greet -- --name Ada prints the return value (name.Length → 3) last on stdout, while the Editor log — including the Hello, Ada! from Debug.Log — streams to stderr:
Starting Unity 6000.0.47f1 (Apple Silicon)...
Waiting for the Pipeline server to start...
Executing "greet" on the Editor...
Command "greet" completed.
3
With --format json, stdout carries a single result envelope instead — data.result is the return value, data.parameters the parsed args, and data.reusedRunningEditor tells you whether an already-open Editor was used:
{
"success": true,
"command": "run",
"data": {
"projectPath": "/path/to/MyProject",
"command": "greet",
"parameters": {
"name": "Ada"
},
"result": 3,
"reusedRunningEditor": false,
"success": true
},
"errors": [],
"warnings": []
}
The Editor log — including Debug.Log output — streams to stderr, and a failed command exits non-zero. Unlike a bare unity run (which forwards args to the Unity executable), --command targets a Pipeline command by name; use unity command / unity list in integration-advanced.md to discover what a connected Editor exposes.
Test — run EditMode/PlayMode tests
# Run tests and write an NUnit XML report (omitting --mode runs the editor's default platform)
unity test /path/to/MyProject
# Run a specific platform (--mode is case-insensitive: EditMode/editmode both work)
unity test /path/to/MyProject --mode EditMode
unity test /path/to/MyProject --mode PlayMode --output ./results/play.xml
# Run only tests whose names match a filter
unity test /path/to/MyProject --filter "MyNamespace.MyTests"
# Pin the editor version, installing it if missing; cap the run at 600 s
unity test /path/to/MyProject --editor-version 6000.0.47f1 --allow-install --timeout 600
# Equivalent via env var:
UNITY_TEST_TIMEOUT=600 unity test /path/to/MyProject
# Forward extra editor args after -- (reserved test flags are rejected)
unity test /path/to/MyProject -- -nographics
# Write a JUnit report for CI instead of NUnit: --output IS the JUnit file
unity test /path/to/MyProject --report-format junit --output ./results/junit.xml
# Write both from one editor run (JUnit defaults to <output>.junit.xml)
unity test /path/to/MyProject --report-format nunit,junit
unity test /path/to/MyProject --report-format nunit,junit --junit-output ./results/ci.xml
# Collect code coverage (requires com.unity.testtools.codecoverage in the project)
unity test /path/to/MyProject --coverage --coverage-output ./coverage
unity test /path/to/MyProject --coverage --coverage-options "generateHtmlReport"
# Split the suite across parallel CI jobs (seed the inventory with one full run first)
unity test /path/to/MyProject # writes test-results.xml
unity test /path/to/MyProject --shard 2/5 # writes test-results.shard-2-of-5.xml
unity test /path/to/MyProject --shard 2/5 --shard-inventory ./ci/full-run.xml
unity test launches the editor's built-in test runner in batch mode (-runTests -testPlatform <mode> -testResults <path> -testFilter <pattern>), waits for it to finish, and writes the report to --output (default test-results.xml). It exits 0 when every test passes, 8 (EXIT_TESTS_FAILED) when the run completed and reported failing tests, and 6 (EXIT_COMMAND_FAILURE) when the run never produced a verdict — a compile error, an unavailable license, an editor crash, an unknown test platform, or --timeout. That split is what lets CI retry an infrastructure failure without ever retrying a failing test; under --format json it also appears as errors[0].code (TESTS_FAILED versus TEST_RUN_ERROR / TEST_TIMED_OUT), so pipelines never have to match the localized message. It runs the tests directly via the editor command line — no pipeline package or server is involved. --mode is optional; when omitted, -testPlatform is not passed and the editor runs its default platform.
It deliberately does not pass -quit: -runTests quits the editor itself once results are written, so forcing -quit would terminate it before the report exists. Anything after -- is forwarded to the editor verbatim, except reserved flags (-projectPath, -batchmode, -runTests, -testPlatform, -testResults, -testFilter, -quit, -useHub, -hubIPC, -enableCodeCoverage, -coverageResultsPath, -coverageOptions), which are rejected — those are managed by the command (use --coverage for the coverage trio); -useHub/-hubIPC are deliberately never passed (the CLI runs no Hub IPC server).
Report formats (CI-native JUnit)
The editor only ever writes NUnit3, so JUnit is produced by converting that report after the run. --report-format decides what --output contains:
--report-format |
--output holds |
Also written |
|---|---|---|
nunit (default) |
NUnit3 — today's behaviour, unchanged | — |
junit |
JUnit | nothing (the editor's NUnit3 goes to a scratch file that is converted and removed) |
nunit,junit |
NUnit3 | JUnit at --junit-output, defaulting to --output with the extension replaced by .junit.xml |
--junit-output is only valid with nunit,junit — with junit alone the JUnit report is --output, so passing both is an error rather than a silent no-op. It also may not resolve to the same file as --output (case-insensitively on Windows): writing both reports to one path would overwrite the NUnit report with the JUnit one while still claiming two artifacts were produced.
All of these flag-combination mistakes, and an unknown --report-format value, are usage errors and exit 2 (EXIT_BAD_ARGS) — not 6 — so a CI script can tell "I invoked the command wrongly" from "the operation failed". They are also checked before the project and editor are resolved, so a usage mistake reports itself rather than surfacing as a missing-editor error.
The JUnit report is written even when tests fail, before the non-zero exit is surfaced — that is exactly when a CI system needs it to annotate the failures. A run whose results cannot be converted (a truncated report from an editor that died mid-write, say) fails the command and names the file it could not read.
GitHub Actions annotations (--format github)
--format github is a global format value — accepted on every command, and settable via UNITY_FORMAT — that emits GitHub Actions workflow commands for failures instead of plain terminal text. It is listed here because CI is where it earns its keep:
unity test /path/to/MyProject --format github
unity build --target StandaloneWindows64 --output-path ./Build/Game.exe --format github
- A failing command annotates the job. Its error message is emitted as
::error::<message>, and a warning as::warning::<message>, so the runner surfaces it as an annotation on the run rather than leaving it buried in the log. - Annotations go to stdout, which is where GitHub's own documented examples emit them.
- Human progress output still renders, so the run stays readable — only the error and warning channels change shape.
- Annotations are not anchored to a file and line yet. Harvesting compile diagnostics out of the editor log (
::error file=…,line=…,col=…) and wrapping that log in a collapsible::group::are not part of this format today, so a compile error annotates with the command's message rather than landing on the diff.
This is complementary to --report-format junit, not an alternative: a JUnit file needs an upload-and-report step and surfaces in a separate tab, while annotations need neither. Using both together is reasonable — and today the JUnit report is what gets you per-test detail.
Outside GitHub Actions the format is inert — the ::-prefixed lines are ordinary text to any other terminal or log collector, so a local --format github run is reproducible and harmless.
Every value is sanitized and percent-encoded before it reaches a workflow command. The protocol is line-oriented and the runner does not escape anything, so an unescaped newline in an editor-supplied message would end the command and let the rest be interpreted as a new one. Never hand-build
::lines around the CLI's output.
Code coverage
--coverage drives Unity's Code Coverage package by passing -enableCodeCoverage -coverageResultsPath <path> (plus -coverageOptions when --coverage-options is given). --coverage-output defaults to CodeCoverage relative to the working directory.
Coverage degrades gracefully: if the project does not depend on com.unity.testtools.codecoverage (checked in Packages/manifest.json, then Packages/packages-lock.json), the CLI prints a warning naming the missing package, skips the coverage flags, and runs the tests normally. It never fails the test run for a missing coverage package — -enableCodeCoverage on a project without it silently produces nothing, which is the confusing outcome this replaces. --coverage-output / --coverage-options without --coverage is an error.
Sharding across parallel CI jobs
--shard N/M runs one deterministic slice of the suite, so a matrix job can take --shard 1/5 through --shard 5/5 and finish in a fifth of the time. N is 1-based and must be between 1 and M; anything else is a usage error (exit 2).
It needs an inventory first. The editor has no way to list tests without running them, so the CLI reads the full test names out of an NUnit3 report an earlier full run already wrote. That report defaults to the unsharded --output path (test-results.xml), and --shard-inventory <path> points at one somewhere else — a cached CI artifact, say. Seed it by running unity test once without --shard. Without a readable inventory the command fails and says so rather than guessing; an inventory holding no test cases fails the same way.
The inventory must be NUnit, not JUnit. The two formats spell the element differently (test-case against testcase), so a JUnit report reads as simply having no tests. Because --report-format junit makes --output the JUnit report, --shard without an explicit --shard-inventory is rejected up front in that combination — a usage error, exit 2 — rather than failing later as an empty inventory. Either point --shard-inventory at an NUnit report, or ask for both formats with --report-format nunit,junit, which keeps --output NUnit. Pointing --shard-inventory at a JUnit file by hand is caught too, and says so specifically.
Assignment is a hash of each test's fully qualified name, which gives the properties a CI matrix depends on:
- Deterministic — the same suite and shard count always produce the same assignment, so a failure reproduces on the shard that reported it.
- Complete and disjoint — every test in the inventory belongs to exactly one shard. Nothing is skipped and nothing runs twice.
- Independent of outcome and timing — nothing about a previous run's results or duration feeds the split, so it does not drift between runs.
- Stable as the suite grows — because the slice follows the name rather than a position in the file, adding a test leaves every other test on the shard it was already on. Changing
Mreshuffles everything, which is inherent to repartitioning.
Precedence with the other options:
| Option | When sharding |
|---|---|
--mode |
Unchanged — selects the platform, and is orthogonal to the split. |
--filter |
Applied by the CLI, to narrow the inventory before the shard is taken from what survives. It has to be: the editor accepts a single -testFilter, and on a sharded run the slice's explicit name list already occupies it, so a --filter passed through would be dropped. Filtering here also makes the tests reported in --format json the ones that will actually run. It accepts the editor's own syntax — a semicolon-separated list of full names or regular expressions, each optionally negated with !. Note this ordering does not rebalance anything: assignment hashes each name independently, so filtering before or after the partition gives the same membership, and a filter can still leave a shard empty. |
--output, --junit-output, --coverage-output |
Every artifact path gains a .shard-N-of-M suffix before its extension, so shards sharing a working directory never overwrite each other and a merge step can collect them by glob. |
Two cases end the run early rather than producing something misleading. A slice with no tests assigned (more shards than tests) skips the editor entirely and exits 0 with a warning — launching with an empty filter would run the whole suite, turning M shards into M full runs. A slice whose assembled filter is too long for a single editor argument fails and tells you to raise the shard count, which shortens every slice. A test whose name contains a semicolon cannot be expressed in the editor's filter syntax at all, so it fails rather than silently not running.
With --format json the envelope reports every artifact, so a pipeline can locate them without guessing:
{
"projectPath": "/path/to/MyProject",
"output": "/path/to/results.xml",
"reports": { "nunit": "/path/to/results.xml", "junit": "/path/to/results.junit.xml" },
"coverage": { "requested": true, "enabled": true, "output": "/path/to/coverage" },
"shard": { "index": 2, "count": 5, "testCount": 2, "tests": ["Acme.Tests.A", "Acme.Tests.B"] }
}
reports.junit is null when JUnit was not requested, reports.nunit is null when only JUnit was. coverage.requested with enabled: false is the missing-package case. shard is absent entirely on an unsharded run, so output every existing script already parses is unchanged.
Retrying failing tests and reporting flakes
--retries N re-runs the tests that failed, up to N extra attempts, stopping as soon as they all pass. Retries are off by default; N must be a whole number, zero or more (anything else is a usage error, exit 2). --timeout applies per attempt, not to the whole sequence.
Only the failing tests are re-run — the CLI reads their names out of the NUnit report the run just wrote and hands them back to the editor as a single -testFilter — so a retry costs the failures, not another full pass.
The point is reporting flakes, not hiding them. The final summary separates three outcomes: passed the first time, passed only on a retry (flaky), and failed every attempt. A flaky test does not silently turn the job green: the run exits 0, because the tests do pass, and the flakiness is stated in the human output, in --format json, and in a file. Tests that never pass still exit 8.
A test leaves the failing set only when a report says it passed. The editor exits 0 for "ran them and they passed", for "matched none of them", and — it reads a filter entry as a name or a regular expression — for "matched a neighbouring test". A test that did not run, ran under a different name, was skipped, or was inconclusive is carried forward, because none of those contradict a failure; so is anything an attempt that failed again did not clear. When an attempt does not clear everything it was given, the survivors stay failing, the run exits 8, and a warning says why.
A run that never produced a verdict is not retried. A compile error, a crashed editor, an expired license or a --timeout all keep exit 6 and stop immediately, without spending the budget: there is no failing set to narrow to, and re-running the same broken configuration would only reproduce the same error. That is the split unity test already draws between exit 8 and exit 6.
Artifacts:
| Path | Contents |
|---|---|
--output (e.g. test-results.xml) |
The first attempt's report — the full-suite record. Deliberately not rewritten to show a flaky test as passing, since that would hide what the retry found. |
test-results.attempt-2.xml, .attempt-3.xml, … |
One report per retry. Not kept under --report-format junit alone, where the NUnit reports live in a scratch directory discarded with the run. |
test-results.retries.json |
The machine-readable retry summary, written whenever a retry ran, passing or failing. It exists because a failing run's JSON envelope carries data: null, and the per-test attempt counts matter most when the run failed. |
A JUnit report is converted from the first attempt too, so a run that went green on a retry still ships a JUnit file containing the original failure. Many CI systems gate on the ingested JUnit artifact rather than the exit code, and for those the flake still shows red — gate on the exit code, or on retries.failed, when you want a flake to pass.
Coverage is collected on the first attempt only. A retry runs a handful of tests, so letting it write the same coverage path would replace the whole suite's coverage with that subset's.
Under --format json the same summary appears as a retries block, absent entirely when --retries was not used or nothing failed. Only tests that failed at least once are listed; passedFirstAttempt counts the rest:
"retries": {
"requested": 2,
"attempts": 3,
"passedFirstAttempt": 41,
"flaky": [{ "test": "Maths.Calc.Adds", "attempts": 2 }],
"failed": [{ "test": "Maths.Calc.Divides", "attempts": 3 }]
}
--rerun-failed starts from the failures a previous run recorded in --output instead of running the whole suite, so a follow-up job can re-check just the failures of the first. It reads the same NUnit report --shard reads its inventory from, honours --filter (applied CLI-side, against the failing set), and can be combined with --retries. It writes to a derived path — test-results.xml becomes test-results.rerun.xml — rather than overwriting --output, which would replace the full-suite record with a partial one and shrink the inventory --shard reads from that same default path.
Three cases end early rather than doing something misleading. Nothing failed → the editor is not started and the run exits 0 with a warning, because an empty test filter is no filter at all and launching would run everything. --report-format junit alone → rejected up front (exit 2): --output is then the JUnit report, and the failing set is read from NUnit, so ask for --report-format nunit,junit. --shard → rejected (exit 2): the editor accepts one test filter and each option needs it, so to retry a shard, run that shard again with --retries.
Running only the tests a change affects (--affected)
--affected --since <ref> runs only the test assemblies the change can reach, taken from unity vcs affected's guid impact graph instead of a hand-written filter. --since defaults to HEAD (uncommitted work) and otherwise uses the revision's merge base with HEAD, like vcs affected.
unity test /path/to/MyProject # seed the inventory: one full run
unity test /path/to/MyProject --affected --since origin/main # writes test-results.affected.xml
unity test /path/to/MyProject --affected-compare --since origin/main
Selection is an approximation, and every run says so. The impact graph is a lower bound: Addressables groups, resource-folder lookups and assets loaded by path or name are real dependencies no static analysis finds. So this trades correctness for time, and the warning naming those classes is printed on every --affected run — human, tsv (on stderr) and as lowerBound plus lowerBoundReason in --format json.
It needs an inventory, for the same reason --shard does. The editor's command line filters by test full name (Namespace.Fixture.Method), which carries no assembly, so resolving affected assemblies into runnable tests needs an NUnit3 report an earlier full run wrote. It reads --output (test-results.xml by default) and writes to a derived path — test-results.affected.xml — so a selective run can never overwrite the full-suite record it will read next time.
Anything it cannot prove runs the whole suite, with a stable untranslated reason token in --format json for a pipeline to log. That happens for: a non-code change (non-code-change — the graph maps changed code to assemblies and has no edge from a prefab to the test that loads it, so a change touching any asset refuses, including one that also touches scripts), a moved assembly boundary (assembly-boundary-changed), a project using Addressables (addressables, read from both Packages/manifest.json and Packages/packages-lock.json so a transitive dependency counts, and an unreadable manifest or settings folder counts as using them), files changed outside the project (outside-project-changes), an empty diff (no-changes), a bad revision (diff-failed), no repository (no-git-repository), unreadable project input (unreadable-input, changed-without-guid), a missing or unattributable inventory (no-inventory, unattributed-tests), an inventory older than the change (stale-inventory), a change that edits a test source at all (changed-test-source — an inventory is keyed on assembly names, so a case added inside an existing test assembly is invisible to it, and selecting anyway would skip the very test the change adds while still exiting 0), a set too large for one test filter (filter-unsendable), and — deliberately — an empty selection (nothing-selected), because "no test is affected" is the largest claim a lower bound can fail to support. A refused run behaves exactly as if --affected had not been passed, so a --filter you also gave is still honoured.
Measure before you trust it. --affected-compare runs every test, then reports what selection would have skipped and, from this run's own results, how many of those tests failed — the false-negative count, alongside the seconds selection would have saved. Both ride the warnings channel so they survive onto a failing run's envelope, which is the run most likely to have one. Use it for a while before switching to --affected.
--affected, --affected-compare, --shard and --rerun-failed are mutually exclusive (exit 2): each decides which tests run, and the editor accepts one test filter. --since without --affected or --affected-compare is a usage error too.
Options: --mode EditMode|PlayMode, --filter <pattern>, --output <path>, --report-format nunit|junit|nunit,junit, --junit-output <path>, --shard <n/m>, --shard-inventory <path>, --retries <n> (0-10), --rerun-failed, --affected, --affected-compare, --since <ref>, --coverage, --coverage-output <path>, --coverage-options <options>, --editor-version <version> (env UNITY_EDITOR_VERSION), -e, --editor-path <path>, -a, --architecture <arch>, --allow-install, --timeout <seconds> (env UNITY_TEST_TIMEOUT).
Watch — re-run affected tests on file change
unity watch test is a thin loop around unity test --affected: it runs once immediately, then re-runs on every project file change, so the inner dev loop no longer means re-typing unity test --affected by hand after each edit.
unity watch test /path/to/MyProject
unity watch test . --filter "MyNamespace.MyTests"
unity watch test . --ignore "*.log" --ignore Recordings
One run at a time, always. A run in flight is always allowed to finish; changes that arrive while it is running only mark the next run pending, and a burst of them (a multi-file save, a branch switch) coalesces into exactly one follow-up run rather than one per file. Two Editor invocations against the same project can never overlap.
Ignored by default: Library/, Temp/, Logs/, Build/, obj/ — matched case-insensitively wherever they appear in the project tree, and no file watcher is even attached to a top-level directory that matches, so Unity's own import churn under Library/ never reaches the CLI. --ignore <pattern> (repeatable) adds more file/directory glob patterns on top of that default set; there is no flag to remove a default entry.
Ctrl-C while idle (between runs, waiting for the next change) exits cleanly with no Editor running. Ctrl-C while a run is in flight terminates the CLI and the spawned Editor together, the same as a plain unity test — never an orphaned Editor process either way.
Refuses to start under --non-interactive, or when a CI environment variable is detected, since the loop has no exit condition of its own and would otherwise hang a job indefinitely.
unity watch test deliberately exposes a narrower flag set than unity test itself — no --shard, --coverage, --retries, --rerun-failed, --affected-compare, or --output/--report-format (the report path is fixed internally, which is also what keeps a run's own report write from re-triggering itself). Reach for unity test --affected directly when you need those.
Options: --mode EditMode|PlayMode, --filter <pattern>, --editor-version <version> (env UNITY_EDITOR_VERSION), -e, --editor-path <path>, -a, --architecture <arch>, --allow-install, --timeout <seconds>, --ignore <pattern> (repeatable).
unity watch build (re-running unity build on change) is a documented follow-up, not yet implemented.
Recompile — compile check without a build
unity recompile answers "does my code compile?" against a running Editor, in seconds, with no artifacts produced. It is the Unity analogue of tsc --noEmit. Reach for it instead of unity build whenever you only need to know whether the scripts are valid — a build is minutes of work to answer the same question.
unity recompile # the Editor for the current project
unity recompile --project-path /path/to/MyGame # pick one when several are running
unity recompile --strict # warnings fail too
unity recompile --format json # structured diagnostics
It triggers an asset refresh plus script recompile, polls until compilation finishes, prints each diagnostic with its file, line, column, code and message, and sets an exit code — so it drops straight into a pre-commit hook or a CI step.
Requires a running Editor with the Pipeline package. That is what makes it fast (the Editor is already warm) and it means no build target has to be configured. There is no batch-mode fallback: with no reachable Editor it reports which Editors it found and what to do, and exits 7.
It cannot report errors that put the Editor into Safe Mode. An Editor that boots with compile errors loads no Pipeline package, so there is nothing to ask and you get exit 7. This command reports errors you introduce into an Editor that is already running — the normal edit loop, where a failed compile keeps the server alive. See the Safe Mode note in SKILL.md for the recovery loop.
Exit codes are the whole point for CI. 0 compiled (or nothing needed recompiling), 6 compilation reported errors, 7 no Editor could be reached, so whether the code compiles is unknown. Treat 6 as terminal and 7 as worth retrying after fixing the environment — you never have to match on the message text. Several Editors running is a 6: pick one with --project-path.
7 covers an Editor that is running but unreachable, not just a missing one — a hung Editor, or your own sandbox refusing the loopback connection. If you are a sandboxed agent, read a 7 as "ask again once the sandbox allows loopback", never as a compile failure.
Warnings do not fail by default. --strict makes them fail. Warnings are read from the Editor console rather than the compile result (the Pipeline package only ever reports errors from a compile), bounded to the entries this compile produced; if the console cannot be read, the command says so on stderr rather than reporting "no warnings".
Diagnostics go to stderr in human and tsv output, so stdout stays clean for parsing. --format json / ndjson carry errors and warnings arrays plus status, failed and compilationFailed — note compilationFailed: true alongside failed: false is normal immediately after a compile that fixed the errors, and is not a failure.
Options: --project-path <path>, --timeout <seconds> (default 120), --focus (bring the Editor forward first; off by default because an unfocused Editor still compiles), --strict.
Build
The first-class build workflow. Rule of thumb vs unity run: building a player → unity build; anything else headless → unity run.
Pick one build strategy: a Unity 6+ Build Profile (--profile), a built-in desktop player build (--target with a desktop target, --output-path required), or a custom --execute-method (your method is responsible for the actual build, including honoring --output-path). Non-desktop targets need --profile or --execute-method.
The build log is always written to the log file and streamed at the same time — to stdout, or to stderr under --format json so stdout holds only the result document; pass --no-tail to write the file only (the tail is also suppressed by --quiet and --format ndjson). The streamed copy masks the credentials the CLI passes the Editor (the -accessToken value, the Android keystore passwords) with ***, as do run and test wherever they relay the Editor's log.
How the outcome is decided — do not gate CI on Unity's exit code alone. For a built-in build (--profile, or --target without --execute-method) the CLI does not trust Unity's process exit code by itself: a real Editor exits 0 from a player build its own log reports as failed. When the log carries Unity's terminal verdict, that verdict decides the outcome, so a failed build exits 6 even where the Unity process exited 0, and the provenance manifest records the failure. A log with no verdict falls back to the exit code, and an --execute-method build always does — your method owns the code it returns, including when it deliberately tolerates a build it reports on itself. A build that succeeds with errors still in its log reports them on stderr rather than dropping them; they also ride editorErrors under --format json.
# Build with a custom build method
unity build /path/to/MyProject \
--target StandaloneOSX \
--execute-method Builder.PerformBuild \
--output-path ./build/output
# Build with a Unity 6+ build profile
unity build /path/to/MyProject --profile "Windows Release" --output-path ./Build/MyGame.exe
# Common build targets: StandaloneOSX, StandaloneWindows64, StandaloneLinux64, Android, iOS, WebGL
Options:
| Flag | Description |
|---|---|
--target <target> |
Build target (required unless --profile is used). |
--execute-method <method> |
Static C# method to invoke, e.g. Builder.PerformBuild. Optional: without it, the CLI uses Unity's built-in build. |
--profile <profile> |
Build profile: a .asset path or a profile name in Assets/Settings/Build Profiles (Unity 6+; the profile defines the target). |
--list-targets |
List every valid --target value — flagged zero-code (the built-in build works) or needing --execute-method / --profile — then exit. |
--list-profiles |
List the project’s Build Profile assets (Unity 6+), then exit. |
--create-profile <target> |
Create a Build Profile for a target and exit without building (Unity 6+); build with it afterwards via --profile. |
--build-target-group <group> |
Forwarded to Unity as -buildTargetGroup. |
-o, --output-path <path> |
Output path. With --execute-method, passed as -buildOutput (your method must honor it); otherwise the built-in build's destination (required). |
-l, --log-file <path> |
Log file path. Default: <project>/Logs/build-<target>-<timestamp>.log. Streamed to stdout by default, stderr under --format json (see --no-tail). |
--editor-version <version> |
Override editor version (default: from ProjectVersion.txt). |
-e, --editor-path <path> |
Use a specific editor binary. |
-a, --architecture <arch> |
Editor architecture (x86_64 or arm64). |
--args <string> |
Extra arguments passed to Unity (shell-split). |
--no-tail |
Do not stream the log to stdout in real time. |
--allow-install |
Install the project's editor version if missing. |
--versioning-strategy <strategy> |
semantic, tag, custom, or none (default: none). |
--build-version <version> |
Explicit version string; only used with --versioning-strategy custom. |
--allow-dirty-build |
Skip the uncommitted-changes guard (default: false). |
--timeout <seconds> (env UNITY_BUILD_TIMEOUT) |
Abort a build that runs longer than this many seconds, exit 6. Disabled by default. |
--provenance-path <path> |
Where to write the provenance manifest. Default: beside the build output, or beside the log file when --output-path is not set. |
--no-provenance |
Do not write the provenance manifest. |
Android signing & export (applied to Android targets only):
| Flag | Description |
|---|---|
--android-export-type <type> |
apk, aab, or android-studio-project. |
--android-keystore-base64 <b64> |
Keystore file, base64-encoded. |
--android-keystore-password <pass> |
Keystore password. |
--android-key-alias <alias> |
Key alias within the keystore. |
--android-key-alias-password <pass> |
Key alias password. |
--android-target-sdk-version <N> |
Target SDK version. |
--android-symbol-type <type> |
none, public, or debugging. |
--android-version-code <N> |
Android version code. |
Keystore flags are validated together. Secrets passed as command-line flags surface in the process list and can be echoed into CI logs. Supply --android-keystore-base64, --android-keystore-password, and --android-key-alias-password from CI secret environment variables (e.g. --android-keystore-password "$KEYSTORE_PASSWORD"), never as inline literals, and source those variables from a dedicated CI secret store. Note that sourcing from an env var only avoids hard-coding the literal — the expanded value still appears in argv, so also mask it in CI log output.
Versioning — semantic and tag derive the version from git tags/history; custom requires an explicit --build-version; a dirty working tree is rejected unless --allow-dirty-build is passed.
Provenance manifest — every build that reaches the editor writes a JSON manifest recording what produced it: editor version and changeset, resolved package set, target, profile, execute method, version stamp, git revision and dirty flag, CLI version, timestamps, and outcome. Failed builds get one too, with the exit code, so they stay diagnosable. It is redacted for publication — paths are project-relative, --args, the Android keystore flags, the editor's install location and the hostname are never written, a git package reference keeps its locator but loses any embedded credentials, and a file: dependency is recorded as file:<local> — so it can be attached to a release next to the artifact. Under --format json / --format ndjson the path is reported as data.provenance (omitted when no manifest was written), on failed builds as well as successful ones. The git revision is captured before the build starts, so an artifact written into the project does not make the manifest claim the build came from a dirty tree. --provenance-path and a relative --output-path both resolve against the current directory, matching what the CLI hands Unity. A manifest that cannot be written warns instead of failing the build. Schema: apps/cli/docs/build-provenance.md.
Output path in the result. Under --format json / --format ndjson the resolved --output-path is reported as data.outputPath: the absolute, cwd-resolved path this build told Unity to write to, exactly as --output-path was supplied (never introspected out of the Editor or the Build Profile asset). It is present for every built-in build (--profile, an auto-discovered profile, or a legacy desktop --target), where --output-path is required, and null only for an --execute-method build invoked without --output-path, where the method picks its own destination the CLI has no way to learn (the same case that leaves nothing for build run to record; see below). Unity decides whether that path names a single file (a Windows .exe, a Linux binary) or a directory it writes into (a macOS .app bundle, a WebGL build, an Android Studio project export); the CLI reports the path it told Unity to use either way, without distinguishing the two. Like version, outputPath is serialized as null rather than omitted when unknown, and it carries the same value on a failed build's payload whenever one is written. This is a different value from the provenance manifest's own build.outputPath (above): the manifest's copy is project-relative and omitted whenever the resolved path falls outside the project, for portability and redaction; data.outputPath is always absolute and never redacted, since it rides the CLI's own stdout rather than a file meant to travel with the artifact.
Interrupt exit codes — interrupting unity build exits with the conventional signal code (130 for Ctrl-C / SIGINT, 143 for SIGTERM) rather than a generic 1, so callers and CI can tell an aborted build apart from a failed one. The temporary Android keystore is scrubbed before exit.
Stall heartbeat — a long build prints a periodic heartbeat (Still building — 4m30s elapsed, last log output 3m10s ago) tracking both total elapsed time and time since the Editor log last grew, so silence in the log no longer looks the same as a hang. Detection itself reads the Editor log's size directly, so it keeps working regardless of output mode — but whether the heartbeat is printed depends on the mode: on the human path it goes to stderr (so the streamed log stays clean) and is unaffected by --no-tail, but --quiet suppresses it entirely in human mode. Under --format json/--format ndjson it appears as periodic progress frames (on stderr under json, on stdout under ndjson) and is emitted regardless of --quiet — quiet only silences the human path.
# With --format json, stdout is the result document alone; the progress frames and the streamed log go to stderr:
unity build /path/to/MyProject --target StandaloneOSX --execute-method Builder.Build --output-path ./build/output --format json > result.json
# stderr (each progress line is a JSON object, the Editor log lines between them):
# {"type":"progress","command":"build","message":"Resolving project..."}
# {"type":"progress","command":"build","message":"Resolving editor..."}
# {"type":"progress","command":"build","message":"Starting Unity..."}
# {"type":"progress","command":"build","message":"Unity exited (code 0)"}
# result.json:
# { "success": true, "command": "build", "data": { "target": "...", "logFile": "...", "outputPath": "/path/to/MyProject/build/output" } }
Discover targets, create profiles, launch the last build
# Every valid --target value, flagged zero-code (built-in build works) or needing --execute-method / --profile
unity build /path/to/MyProject --list-targets --format json
# The project’s Build Profile assets (Unity 6+) — the supported way to build partner or
# package-provided platforms, such as Meta Quest, that are not in --target’s list
unity build /path/to/MyProject --list-profiles --format json
# Create a Build Profile for a target and exit without building (Unity 6000.0+), then build with it
unity build /path/to/MyProject --create-profile WebGL
unity build /path/to/MyProject --profile WebGL --output-path ./Build/web
# Launch the project’s most recent recorded build without rebuilding (recorded = built with a known --output-path)
unity build run /path/to/MyProject
unity build run /path/to/MyProject --path ./Build/other/MyGame.exe # a different recorded build
--list-targets, --list-profiles and --create-profile each do their job and exit — no build happens. A --target outside the classic catalog fails with BUILD_INVALID_TARGET and points at --profile. A successful unity build with a known output path — a built-in build (--output-path is required there) or a --profile / --execute-method build that passed --output-path — records that path together with the target, architecture and Editor version, and build run launches that recording: a desktop player natively, a WebGL build from a loopback-only local HTTP server that opens in the default browser. An --execute-method build without --output-path chooses its own destination inside the method, so it records nothing and leaves any earlier record in place; launch such a build with build run --path <output> instead. build run fails cleanly (exit 6) when no build has been recorded yet, when the recorded build’s platform cannot run on this OS, or when its output is gone.
Per-project defaults. A committed ProjectSettings/UnityCliConfig.json declares unity build / unity test defaults (build.target, build.outputPath, build.profile, build.timeout, test.mode, test.reportFormat, test.coverage, test.coverageOptions, test.timeout) so they need not be repeated on every invocation; unity config resolve <key> [project] shows the resolved value and which layer supplied it. See config-hub.md.
Unity Accelerator — shared asset-import cache
The Unity Accelerator is Unity's asset-import cache server: on import the Editor downloads a prebuilt artifact on a hash hit instead of re-importing. It pays off exactly where the CLI is used most — clean CI checkouts, ephemeral containers, branch switches, parallel agent worktrees.
Persist the endpoint once with unity config accelerator <host:port> (see config-hub.md), and run, test and build inject it automatically. Per-invocation control:
| Flag | Description |
|---|---|
--accelerator <host:port> |
Endpoint for this invocation. Also via UNITY_ACCELERATOR. A bare host defaults to port 10080. |
--no-accelerator |
Ignore any configured Accelerator for this invocation. |
Both are scoped to run, test and build, and go after the command name. They are deliberately not root globals: every other command would accept an endpoint it can never act on, then still fail as a usage error on a malformed value. unity --accelerator … build is an unknown option error (exit 2) — put the flag after the command instead.
# Uses the persisted endpoint automatically
unity test /path/to/MyProject
# One-shot override / opt-out
unity build /path/to/MyProject --target StandaloneLinux64 --output-path ./Build/MyGame.x86_64 --accelerator cache.example.com:10080
unity test /path/to/MyProject --no-accelerator
When an endpoint resolves, the CLI appends -EnableCacheServer -cacheServerEndpoint <host:port> to the Editor argv and says so (Using Unity Accelerator at … (source: settings).). When nothing resolves, the argv is byte-identical to a CLI without this feature. --no-accelerator reports that it suppressed a configured endpoint, which is deliberately distinct from the silence of a machine that has none.
unity run, unity test and unity build inject. unity open does not — interactive opens have their own argv builder and are a known follow-up; configure the Accelerator in Editor Preferences for interactive work in the meantime.
Reserved: -EnableCacheServer and -cacheServerEndpoint are rejected if you forward them yourself (exit 6) — the CLI manages that pair. Matching is case-insensitive and covers the --flag and -flag=value spellings.
Everything else in the family stays forwardable, and the injected pair is placed before your tail so Unity's last-wins parser lets your values win: -cacheServerEnableImportResultCaching, -cacheServerNamespacePrefix, -cacheServerEnableDownload true|false, -cacheServerEnableUpload true|false, -cacheServerWaitForConnection <ms>, -cacheServerDownloadBatchSize <n>, -cacheServerUploadExistingImports, -cacheServerUploadAllRevisions, -cacheServerUploadExistingShaderCache, and the -disableShaderCacheRemote* / -disableTextureCacheRemote* families (each with …Download / …Upload sub-variants).
unity test /path/to/MyProject -- \
-cacheServerEnableImportResultCaching \
-cacheServerNamespacePrefix "ci-6000.0" \
-cacheServerWaitForConnection 10000
Import result caching is disabled by default from Unity 6.5 in new projects. On a new 6.5+ project, connecting to an Accelerator is not by itself enough to get import-result reuse — pass -cacheServerEnableImportResultCaching or enable it in the project.
A configured endpoint can still be ignored. Every -cacheServer* argument overrides Editor Preferences, not Project Settings; ProjectSettings/EditorSettings.asset's m_CacheServerMode decides whether preferences are consulted at all (0 = Use global settings, 1 = Enabled, 2 = Disabled). A project on mode 2 ignores the injected flags, and the CLI emits a warning (never a failure — the run proceeds). Mode 1 is not warned about: whether a project-pinned endpoint beats an injected one is unmeasured, and a false warning would be worse than none. Diagnose with unity diagnose accelerator (see diagnostics-maintenance.md); full explanation in apps/cli/docs/accelerator.md.