โ๏ธ Lesson 8.2: Automated & Cloud Builds (CI/CD)
You can build your game โ File โธ Build, wait, done. But building by hand is slow, forgettable, and irreproducible: which platform settings were on? did the tests pass first? was it the committed code or your uncommitted tweaks? Continuous integration answers all three by having a server build and test your project automatically on every push. This lesson takes you from the command line that drives Unity headlessly, through a C# build script, to a real GitHub Actions pipeline โ and the cloud alternatives when you'd rather not manage machines.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Run Unity headlessly with
-batchmode -quit -projectPath -executeMethod -buildTarget - Write a C#
BuildScriptthat callsBuildPipeline.BuildPlayerwithBuildPlayerOptions - Explain what CI/CD is and why it beats building by hand
- Configure a minimal GitHub Actions workflow using
game-ci/unity-builder - Weigh self-hosted CI against Unity Build Automation (formerly Cloud Build)
- Handle Unity licensing/activation in CI and produce versioned artifacts
Estimated Time: 70 minutes ยท Prerequisite: Lesson 8.1 (you'll run your tests in CI); Git basics; a project under version control
In This Lesson
What CI/CD Buys You
Continuous Integration (CI) means every change pushed to the repository is automatically built and tested by a server, so integration problems surface within minutes of the commit that caused them. Continuous Delivery/Deployment (CD) extends that: a successful build is automatically packaged and handed to testers, a store, or players.
The concrete wins: builds are reproducible (a clean checkout on a clean machine, not your cluttered working copy), guarded (the pipeline runs Lesson 8.1's tests and refuses to ship a red build), and parallel (Windows, macOS, Android, and WebGL players from one push while you keep working). The moment a teammate breaks the build, everyone knows โ not three days later when someone tries to make a release.
๐ Definition
A CI runner (or "agent") is the machine that executes your pipeline: it checks out the commit, restores the Unity project, activates a license, runs tests, invokes the build, and uploads the result. It can be a cloud VM spun up per job (GitHub-hosted), a machine you own (self-hosted), or Unity's own build farm.
Driving Unity from the Command Line
Every automated Unity build rests on one fact: the Editor can run without its window, driven entirely by command-line arguments. This is batch mode. The canonical invocation:
Unity -batchmode -quit -nographics \
-projectPath /path/to/Project \
-buildTarget StandaloneWindows64 \
-executeMethod BuildScript.PerformBuild \
-logFile -
Each flag earns its place:
-batchmodeโ no interactive Editor UI; suppresses dialogs so nothing blocks waiting for a click.-quitโ exit the Editor when the invoked method returns (omit it and the process hangs open forever on the runner).-nographicsโ don't initialise a graphics device; fine for building and headless tests on a GPU-less server.-projectPathโ which project to open.-buildTargetโ the platform to switch to before building (e.g.StandaloneWindows64,StandaloneOSX,Android,WebGL).-executeMethodโ apublic staticmethod Unity calls once the project is open; this is your entry point into custom build code.-logFile -โ stream the Editor log to stdout so the CI console shows what happened.
To run the test suite headlessly instead of building, swap in -runTests -testPlatform EditMode -testResults results.xml. Unity exits with a non-zero code if any test fails โ which is exactly what lets CI gate the build on green tests.
โ ๏ธ Batch mode exits with a status code โ respect it
A headless Unity returns 0 on success and non-zero on failure. Your build method must call EditorApplication.Exit(1) on error so the runner sees the failure; if you swallow the exception and return normally, CI thinks the build "succeeded" and merrily ships a broken binary.
A C# BuildScript
The -executeMethod target is ordinary Editor C#. It lives in an Editor folder (or Editor-only assembly) and uses BuildPipeline.BuildPlayer to produce the player. Here is a complete, CI-ready script:
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
public static class BuildScript
{
// The scenes to include, in order. Pulling from the Build Settings list
// keeps this in sync with what you test in the Editor.
private static string[] Scenes =>
System.Array.FindAll(EditorBuildSettings.scenes, s => s.enabled)
.Length == 0
? new[] { "Assets/Scenes/Main.unity" }
: System.Array.ConvertAll(
System.Array.FindAll(EditorBuildSettings.scenes, s => s.enabled),
s => s.path);
// Called by: -executeMethod BuildScript.PerformBuild
public static void PerformBuild()
{
// Allow CI to override the output path via an env var.
string outDir = System.Environment.GetEnvironmentVariable("BUILD_OUTPUT")
?? "Builds/Windows";
string exePath = $"{outDir}/MyGame.exe";
var options = new BuildPlayerOptions
{
scenes = Scenes,
locationPathName = exePath,
target = BuildTarget.StandaloneWindows64,
options = BuildOptions.None // add BuildOptions.Development for dev builds
};
BuildReport report = BuildPipeline.BuildPlayer(options);
BuildSummary summary = report.summary;
if (summary.result == BuildResult.Succeeded)
{
Debug.Log($"Build OK: {summary.totalSize} bytes in {summary.totalTime}");
EditorApplication.Exit(0);
}
else
{
Debug.LogError($"Build FAILED with {summary.totalErrors} errors");
EditorApplication.Exit(1); // non-zero โ CI marks the job failed
}
}
}
The shape is always the same: assemble a BuildPlayerOptions (scenes, output path, target, options), call BuildPipeline.BuildPlayer, inspect the returned BuildReport, and translate its result into a process exit code. Reading the output path from an environment variable lets the same script serve every platform your CI matrix builds โ the pipeline sets BUILD_OUTPUT, the script obeys.
๐ก The BuildReport is a goldmine. Beyond pass/fail,reportexposes per-step timings and a breakdown of what went into the build. Loggingsummary.totalSizeover time is a cheap way to catch the day someone accidentally ships 2 GB of uncompressed textures.
The Pipeline, End to End
Before wiring specific tools, hold the whole flow in your head. A push triggers the runner, which checks out the commit, runs the tests from Lesson 8.1, and only if they're green invokes the build script above โ then publishes the resulting player as a downloadable artifact and, optionally, deploys it.
commit"] --> B["๐ฅ๏ธ CI runner
checks out code"] B --> C["๐ Activate
Unity license"] C --> D["๐งช Run tests
-runTests"] D -->|"โ red"| X["๐ซ Fail the job
notify team"] D -->|"โ green"| E["โ๏ธ Build player
-executeMethod"] E --> F["๐ฆ Upload artifact
versioned zip"] F --> G["๐ Deploy
testers / store"]
Figure 1: A Unity CI/CD pipeline. A commit triggers a fresh runner that activates a license, runs the test suite, and gates the build on green tests. A passing build becomes a versioned artifact and can flow on to deployment. The red branch stops the line and alerts the team โ the whole point of CI.
Every CI system โ GitHub Actions, GitLab CI, Unity Build Automation โ is a way of expressing this same graph. Learn the shape once and the specific YAML is just dialect.
GitHub Actions with game-ci
The most common self-service option is GitHub Actions driving the open-source GameCI actions, which package Unity into Docker images and handle the batch-mode plumbing for you. A workflow file lives at .github/workflows/build.yml. Here's a minimal test-then-build pipeline:
name: Build
on: [push]
jobs:
test-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true # Git LFS for large assets
- name: Run tests
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
testMode: editmode
- name: Build
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
targetPlatform: StandaloneWindows64
buildMethod: BuildScript.PerformBuild
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MyGame-Windows
path: build/
Read it as the graph from Figure 1: check out (with LFS for big assets), run the Edit Mode tests, build calling your BuildScript.PerformBuild, then upload the output folder as a downloadable artifact. The unity-builder action supplies the Docker image with the right Unity version, invokes batch mode, and injects the license from a secret โ so you never write the raw -batchmode command yourself, but everything under the hood is exactly the flags from earlier in this lesson.
โ ๏ธ Match the Unity version, or waste an hour
GameCI picks the Editor version from your project's ProjectSettings/ProjectVersion.txt. If the exact version image doesn't exist yet (a brand-new release) the job fails cryptically. Pin a known-good version and bump deliberately โ a version mismatch between your machine and the runner is the number-one first-pipeline frustration.
Licensing & Activation in CI
Unity refuses to run headlessly without an activated license, and a CI runner is a throwaway machine with no logged-in user โ so activation is the step that trips up every first pipeline. The mechanism depends on your license type:
๐ Personal
Generate a manual activation file (.alf) once, exchange it on Unity's site for a .ulf license file, and store that file's contents in a CI secret (UNITY_LICENSE). GameCI activates from it at the start of each job.
๐ผ Pro / Plus
Use serial-based activation: store UNITY_SERIAL, UNITY_EMAIL, and UNITY_PASSWORD as secrets. The runner activates against Unity's licensing server on start and returns the seat on finish so you don't exhaust your activations.
Whichever path, the license lands in encrypted secrets, never in the repository. Treat a leaked .ulf or serial like a leaked password.
โ ๏ธ Never commit license files, serials, or credentials
A .ulf in git history or a serial echoed into a public log is a real leak. Use your CI provider's secret store, mask values in logs, and rotate anything that slips out. This is exactly why the workflow reads ${{ secrets.UNITY_LICENSE }} rather than an inline string.
Unity Build Automation
If you'd rather not manage runners, licenses, and Docker images yourself, Unity Build Automation (part of Unity Gaming Services, and the successor to what was called Cloud Build) is Unity's hosted build farm. You connect your Git repository through the Unity Dashboard, define build targets per platform, and Unity's servers build on every push โ including notoriously fiddly platforms like iOS, where they manage the macOS + Xcode toolchain for you.
The trade-off is the usual build-vs-buy: a managed service means less setup and no infrastructure to babysit, but less control and a usage-based cost as your team and build frequency grow. Many studios start on Build Automation for convenience and move to self-hosted GitHub Actions once they need custom steps or want to control cost. Both drive the very same BuildScript.PerformBuild you wrote โ the build logic is portable; only the orchestration changes.
๐ It's a spectrum, not a binary. GitHub-hosted runners, self-hosted runners, and Unity Build Automation trade convenience against control and cost in that order. Pick by what's scarce on your team: if it's time and ops skill, lean managed; if it's money and you need bespoke steps, lean self-hosted.
Artifacts & Versioning
A build the pipeline throws away is useless; the output must become a durable, identifiable artifact. Two habits make artifacts trustworthy:
Stamp every build with a version. Set PlayerSettings.bundleVersion and the platform build number from the pipeline โ a common scheme is a semantic version from a git tag plus the CI run number and short commit hash, e.g. 1.4.0+312.a1b9c4. Now any binary a tester reports a bug in maps back to an exact commit.
// Called early in PerformBuild, before BuildPlayer.
string version = System.Environment.GetEnvironmentVariable("BUILD_VERSION") ?? "0.0.0";
string build = System.Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER") ?? "0";
PlayerSettings.bundleVersion = version;
PlayerSettings.macOS.buildNumber = build;
PlayerSettings.Android.bundleVersionCode = int.Parse(build);
Name and retain artifacts deliberately. Upload the player folder under a name that includes platform and version (MyGame-Win64-1.4.0-312.zip), and set a retention policy so you can always fetch the exact build a report references. Artifacts are also how CD hands off downstream โ the same zip a tester downloads is the one a deploy step pushes to a store channel.
โ The discipline in one line
Every push produces a tested, versioned, retained build traceable to an exact commit โ so "which build?" and "does it pass?" are never open questions again.
Hands-on Challenge
๐๏ธ Exercise 1: A two-target build script
Objective: Extend BuildScript so one method can build either Windows or WebGL, chosen by an environment variable.
Add a PerformBuild that reads BUILD_TARGET ("Windows" or "WebGL"), sets the matching BuildTarget and output path/extension, and otherwise reuses the pattern from the lesson. Remember the exit-code contract.
โ Solution sketch
public static void PerformBuild()
{
string which = System.Environment.GetEnvironmentVariable("BUILD_TARGET") ?? "Windows";
var opts = new BuildPlayerOptions { scenes = Scenes };
if (which == "WebGL")
{
opts.target = BuildTarget.WebGL;
opts.locationPathName = "Builds/WebGL"; // a folder
}
else
{
opts.target = BuildTarget.StandaloneWindows64;
opts.locationPathName = "Builds/Windows/MyGame.exe";
}
var report = BuildPipeline.BuildPlayer(opts);
EditorApplication.Exit(report.summary.result == BuildResult.Succeeded ? 0 : 1);
}
WebGL builds to a folder (there's no single executable), so the output path differs โ a good reminder that locationPathName semantics vary by platform.
๐๏ธ Exercise 2: Gate the build on tests
In the GitHub Actions workflow, the test step runs before the build step. Explain why that ordering โ plus batch mode's exit code โ means a failing test prevents an artifact from ever being produced.
โ Answer
Steps in a job run sequentially, and a step that exits non-zero fails the job and stops the remaining steps by default. unity-test-runner runs Unity with -runTests, which exits non-zero if any test fails. So a red suite fails the test step, the build step never runs, and no artifact is uploaded. The tests gate the build โ CI can only ship what passes.
๐ฏ Quick Quiz
Question 1: What does the -quit flag do in a batch-mode Unity invocation?
Question 2: Which API actually produces the player inside your build script?
Question 3: Why must the build method call EditorApplication.Exit(1) on failure?
Question 4: Where should a Unity license file or serial live for a CI pipeline?
Summary
๐ Key Takeaways
- CI/CD builds and tests every push on a server, giving reproducible, test-gated, parallel builds instead of forgettable manual ones.
- Unity runs headlessly in batch mode:
-batchmode -quit -nographics -projectPath -buildTarget -executeMethod, with-runTeststo run the suite and a status code that reports success. - A C#
BuildScriptassemblesBuildPlayerOptions, callsBuildPipeline.BuildPlayer, inspects theBuildReport, and translates the result into an exit code viaEditorApplication.Exit. - GitHub Actions +
game-ci/unity-builder/unity-test-runnerexpresses the checkout โ test โ build โ artifact pipeline; the tools wrap batch mode for you. - Licensing in CI uses encrypted secrets (a
.ulffor Personal, serial + credentials for Pro) โ never committed. - Unity Build Automation is the managed alternative; both drive the same build script. Stamp every artifact with a version traceable to its commit.
๐ What's Next?
Your game now builds and tests itself on every push. But shipping isn't the end โ it's the start of the loop where you learn what players actually do. In Lesson 8.3: Live-Ops โ Analytics, Remote Config & Crash Reporting you'll see how to measure real player behaviour, change balancing and feature flags without a new build, and catch crashes in the wild โ turning a shipped build into a game you keep improving.
โ๏ธ The machine builds for you now
Every commit becomes a tested, versioned build without you lifting a finger. That's what frees a team to move fast without breaking things.