Up to now, "does the game work" has mostly meant "does it work when I press play on my own computer." That question stops being good enough the moment more than one person touches a project. A real game is built by a team, tested by people who are not the programmer who wrote the change, and eventually installed on hardware nobody on the team owns. This section is about the machinery studios build so that a change someone makes on their own laptop can be proven to work everywhere else too, automatically, within minutes, and can end up on a tester's phone without anyone copying a file by hand. None of this is exotic — it is ordinary infrastructure at every studio that ships anything, from a two-person indie team to a HoYoverse-sized production.
Picture three programmers on the same small team. Alex has Unity 2022.3.10f1 installed, with the Android build module and a slightly newer Android SDK than everyone else, because Alex updated it last week for an unrelated reason. Priya has Unity 2022.3.4f1, an older patch version, because she has not updated in a month. Sam has the same Unity version as Alex, but also has three files sitting in the project folder that were never committed to git — leftover experiments from a feature that got abandoned, still sitting on disk, quietly being picked up by the Unity Editor even though nobody else on the team has them.
Now Alex writes a new feature, presses play, it works, and pushes the change. On Alex's machine, this is completely true — the feature works. But "works" here secretly depended on the newer Android SDK Alex happens to have installed. Priya pulls the change the next morning, builds, and gets a compile error referencing an API her older SDK does not have. Sam pulls the same change, and it builds fine — but only because one of those three stray leftover files happens to define a class the new code silently depends on, a class that does not exist anywhere in git at all.
Every one of these is a version of the same problem, usually summed up as "it works on my machine." It is not a joke about a lazy excuse — it is a precise description of a build that only succeeds because of something true about one specific computer that is not true about the codebase itself: an installed tool version, a stray uncommitted file, an environment variable set months ago and forgotten, a cached package that predates a dependency change. None of those things live in git. All of them can make a build pass for one person and fail for everyone else.
The cost is not hypothetical. A broken build that nobody notices for a few days does not stay a small problem — more work gets built on top of the broken code before anyone even knows something is wrong.
That is hours of a programmer's time spent hunting for a mistake that, if it had been caught the day it was made, would have taken minutes to fix — because the person who wrote it still remembered exactly what they had just changed. The rest of this section is about closing that gap between "a mistake is made" and "a mistake is caught."
The core idea is almost embarrassingly simple: stop trusting any individual developer's machine to answer the question "does this actually build." Instead, use one separate machine — a build server (also called a CI server or CI runner, where CI stands for continuous integration, covered in the next section) — whose only job is to check out a completely fresh copy of the project from git and try to build it, with nothing left over from any previous attempt.
The build server is deliberately boring. It does not have anyone's personal tool preferences, unfinished experiments, or forgotten environment variables. Every single time it runs, it starts from the same clean state and only ever sees what is actually committed to the repository. That single property is what makes its answer trustworthy: if the build server says the project builds, that is true for anyone who checks out the same commit, not just true by accident for one laptop.
A build server by itself is just a machine that can build the project once, on demand. The next four sections are about what actually happens during that build, and how the trigger to run it gets automated so nobody has to remember to ask.
A build pipeline is the ordered sequence of steps a build server runs, from "here is a commit" to "here is a finished, installable build." Every studio's exact pipeline looks a little different, but almost all of them follow the same shape:
Walking through each stage:
Packages/manifest.json, NuGet packages for a .NET project, third-party libraries — gets downloaded. This step exists because dependencies are usually not committed into git directly; only a list of which versions are needed is..apk, an iOS .ipa, a Windows .exe plus its data folder. A studio shipping on multiple platforms repeats this stage once per platform, because each one needs its own separate build.Nothing about this list is Unity-specific yet — the same shape applies to a mobile app, a website, or a desktop tool. Sections 6 through 8 make several of these stages concrete for a Unity project specifically.
Continuous integration (usually shortened to CI) is the practice of running the build pipeline from Section 3 automatically, every single time someone pushes a commit — not once a week when someone remembers, not only right before a release, but on every push, without a human having to trigger it. The name refers to integrating (merging) everyone's changes together constantly and checking, every time, that the result still works.
The value of CI is almost entirely about timing. Compare what Section 1's broken build looked like without CI to what it looks like with CI:
This is often called the fast feedback loop: the shorter the time between making a mistake and finding out about it, the cheaper that mistake is to fix, because the context is still fresh in the mind of the person who made it. A CI system's whole purpose is to make that loop as short as it can afford to be — Section 5 explains why "as short as it can afford" is not the same as "instant" for every kind of check.
Here is a genuinely simple GitHub Actions workflow — a configuration file, written in YAML, that tells GitHub's own build servers exactly what to run and when. This example uses a plain C# project built with the dotnet command-line tool, kept deliberately simple to show the core CI concept clearly; Section 6 extends this same idea to a full Unity project.
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v4
- name: Restore packages
run: dotnet restore
- name: Compile
run: dotnet build --no-restore
- name: Run tests
run: dotnet test --no-build
Reading it line by line:
name: CI — the name shown for this workflow in GitHub's own interface. Purely for humans.on: — the trigger. This workflow runs when someone pushes to the main branch, and also whenever a pull request targets main. This is what makes it automatic — nobody has to remember to click a button.jobs: then build-and-test: — defines one named job. A workflow can have several jobs running in parallel, but this one has just the single job.runs-on: ubuntu-latest — this is the build server from Section 2. GitHub hands this job a brand-new, disposable Linux virtual machine that has never run anything before.steps: — the ordered list of commands, run one after another on that fresh machine.actions/checkout@v4 — this is Section 3's "pull latest code" stage: it clones the exact commit that triggered this run onto the fresh machine.dotnet restore — Section 3's "restore packages" stage: downloads whatever dependencies the project's package file lists.dotnet build --no-restore — Section 3's "compile" stage.dotnet test --no-build — Section 3's "run tests" stage.If any one step exits with a failure (a compile error, a failing test), GitHub Actions stops the job right there, marks it red, and — depending on how the team has it configured — can block that change from being merged at all until it is fixed. That is the whole mechanism: no step here is complicated on its own, the value comes entirely from it running automatically, every single push, without anyone needing to ask.
Section 4's example runs in well under a minute. A real game project's full pipeline does not. Building the actual player for every platform a studio ships on, running a complete test suite instead of a quick subset, and running longer checks like memory-leak soak tests (leaving the game running for hours, watching whether memory usage keeps climbing) or performance benchmarks can easily take an hour or more, sometimes several hours.
Running all of that on every single push would technically still be "continuous integration," but it would destroy the exact thing that makes CI valuable: the fast feedback loop from Section 4. Nobody wants to wait two and a half hours to find out whether the one-line fix they just pushed compiles. So most studios split the pipeline into two tiers instead of running everything, every time.
A nightly build is exactly this second tier: a scheduled job that runs the expensive, thorough version of the pipeline at a fixed time, usually overnight when nobody is waiting on the result and the build servers are otherwise idle. In GitHub Actions, this uses a schedule trigger with a cron expression (a compact syntax for "run at this time, on this schedule," used across many scheduling tools, not just CI):
on:
schedule:
- cron: '0 2 * * *'
A cron expression has five fields: minute, hour, day-of-month, month, day-of-week, each one either a specific number or * meaning "any." 0 2 * * * reads as "at minute 0 of hour 2, any day of any month, any day of the week" — in other words, 02:00 every single day. Note that GitHub Actions schedules run in UTC, which is worth double-checking against a team's actual working hours.
The two tiers work together, not against each other: the fast push-triggered job catches the vast majority of ordinary mistakes within minutes, and the nightly job catches the smaller category of problems that only show up under a full platform matrix, a full test suite, or a long soak — problems that are real, but not worth making every single developer wait for on every single push.
A build server, as established in Section 2, has no monitor attached and nobody sitting in front of it. That is a problem for the Unity Editor by default, since it normally expects to open a graphical window. Unity solves this with batch mode: a way of running the Editor entirely from the command line, with no window at all, suitable for a machine nobody is looking at — often called running headless.
The actual build logic lives in an ordinary C# script placed in an Editor folder in the project, using Unity's UnityEditor namespace, which is only available inside the Editor (never in a shipped build). A typical build script looks like this:
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;
public static class BuildScript
{
// A human can also trigger this from the Editor menu -- useful
// for testing the exact same build code CI will run.
[MenuItem("Build/Build Android (CI)")]
public static void BuildAndroidFromMenu()
{
BuildAndroid();
}
// CI calls this headlessly with:
// -executeMethod BuildScript.BuildAndroid
public static void BuildAndroid()
{
string[] scenes =
{
"Assets/Scenes/MainMenu.unity",
"Assets/Scenes/Gameplay.unity"
};
BuildPlayerOptions options = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = "Builds/Android/game.apk",
target = BuildTarget.Android,
options = BuildOptions.None
};
BuildReport report = BuildPipeline.BuildPlayer(options);
if (report.summary.result != BuildResult.Succeeded)
{
Debug.LogError("Build failed: " + report.summary.result);
EditorApplication.Exit(1); // non-zero exit code tells CI this job failed
}
}
}
BuildPipeline.BuildPlayer is the same underlying function the Editor calls when a human clicks "Build" in the Build Settings window — this script just calls it directly from code instead, with a fixed list of scenes and settings instead of whatever happens to be configured in the Editor's UI at that moment. That matters: a build triggered from code is reproducible, driven entirely by what is written and committed here, not by whatever a particular person's Editor window happened to have checked at the time.
The command line that actually runs this on a build server looks like:
"/Applications/Unity/Hub/Editor/2022.3.10f1/Unity.app/Contents/MacOS/Unity" \
-batchmode \
-quit \
-nographics \
-projectPath . \
-executeMethod BuildScript.BuildAndroid \
-logFile build.log
Each flag matters:
-batchmode — run without opening the normal graphical Editor window at all.-quit — exit automatically once whatever was requested finishes, instead of sitting open waiting for a human.-nographics — skip initializing a graphics device entirely, since a CI runner often has no GPU or display driver available to initialize in the first place.-projectPath . — the folder containing the Unity project to open, here "the current directory," since the pipeline already checked the repository out there.-executeMethod BuildScript.BuildAndroid — once the Editor has loaded headlessly, call this exact static method. This is the bridge between the generic Unity command line and the project-specific build code shown above.-logFile build.log — write everything the Editor prints during this run to a file, so if the build fails, there is a full, readable log to look at afterward instead of only a pass/fail result.EditorApplication.Exit(1) in the script above is what actually connects a Unity-level failure back to CI: a normal process exit code of 0 means success, and any non-zero code means failure. Without that explicit call, Unity could quietly finish with a failed build yet still exit with code 0, and the CI pipeline would report a false green result.
Library folder, log files, or build output (like the Builds/ folder from the script above) into git. They are large, machine-generated, and would immediately reintroduce a version of the "it works on my machine" problem from Section 1, since a stale committed Library folder could hide a real reimport bug. Keep them in .gitignore; Section 7 covers the right way to speed up builds without committing this folder.There is one more requirement before any of this runs: the Unity Editor will not run at all — not even in batch mode — without an activated license. A freshly created, disposable build server (Section 2's whole point) has never seen a license before, on every single run.
Studios handle this one of two ways. A long-lived, self-hosted build machine can be activated once, keeping a persistent license file on disk between runs, the same way a normal Editor installation would be. A short-lived, cloud-provided runner — a fresh virtual machine every time, as in the GitHub Actions examples so far — usually activates at the start of every single job instead, using credentials pulled from CI's encrypted secrets storage, never written directly into the workflow file:
- name: Activate Unity license
run: |
Unity -batchmode -nographics -quit \
-username "$UNITY_EMAIL" \
-password "$UNITY_PASSWORD" \
-serial "$UNITY_SERIAL"
env:
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
secrets.UNITY_EMAIL and its siblings refer to values a repository administrator stores once, in GitHub's own encrypted secrets settings — never inside the YAML file itself, and never visible again in plain text once saved, including to anyone just reading the workflow. GitHub Actions substitutes the real value into the environment only while the job runs.
Every asset in a Unity project — every texture, model, audio clip, and script — gets processed once into an internal format the Editor and the build process actually use. That processed data lives in a folder named Library, sitting right next to Assets in the project. It is regenerated automatically any time it is missing, which is exactly what happens on a build server: since Section 2 established that every run starts from a completely fresh checkout, and Library is correctly listed in .gitignore (per this section's warning above), it simply does not exist yet when a fresh CI job begins.
Regenerating it from nothing means reimporting every single asset in the entire project, from scratch, before Unity can even begin compiling scripts or building a player. For a real game with several thousand assets, that alone — before any actual building happens — can take twenty minutes, forty minutes, sometimes longer, on every single CI run, even for a one-line code change that touched nothing about any asset.
The fix follows directly from the diagram: save the Library folder somewhere after a successful run, and restore it before Unity even starts on the next run, so Unity only has to reimport what actually changed since last time. GitHub Actions provides this as a built-in feature:
- name: Cache Library folder
uses: actions/cache@v4
with:
path: Library
key: Library-${{ runner.os }}-${{ hashFiles('Packages/manifest.json') }}
restore-keys: |
Library-${{ runner.os }}-
path: Library — exactly which folder gets saved after the job and restored before the next one.key: — the exact cache to look for. It includes the operating system (a Linux-built cache is not safe to reuse on Windows) and a hash of Packages/manifest.json — the file listing the project's package dependencies — so that changing a dependency automatically invalidates the old cache instead of silently reusing stale, incompatible import data.restore-keys: — a fallback list. If no cache exactly matches the current key (say, because one package version just changed), this tells the action to fall back to the most recent cache that at least matches the Library-linux- prefix, rather than starting from nothing. Most of the folder is still reused; only what actually needs to change gets reimported.The build server itself is thrown away — or at least wiped clean — after each job finishes. Whatever the pipeline actually produced needs to be saved somewhere durable before that happens; that saved output is called an artifact: the actual .apk, .ipa, or Windows build folder a pipeline run produced, kept around so it can be downloaded, inspected, or handed to a tester later.
A pile of artifacts is only useful if each one can be told apart from every other one, precisely. Two pieces of information make that possible together:
github.run_number.a3f9c21. The build number alone only tells you it was, say, the 238th build ever produced; the git hash tells you exactly which lines of source code produced it.Together, embedding both directly into the build means a bug report never has to say "the version from sometime last week" — it can say exactly which commit to check out to reproduce the exact same code the tester ran. Here is a version-stamping step, added to the BuildScript from Section 6, that runs before the actual build:
using System;
using System.Diagnostics;
using System.IO;
using UnityEditor;
using UnityEngine;
public static class VersionStamp
{
public static string WriteVersionInfo()
{
string gitHash = RunGit("rev-parse --short HEAD");
string buildNumber = Environment.GetEnvironmentVariable("GITHUB_RUN_NUMBER") ?? "local";
string version = $"{Application.version}+{buildNumber}.{gitHash}";
// written into Resources so the running game can read it back
File.WriteAllText("Assets/Resources/version.txt", version);
AssetDatabase.Refresh();
UnityEngine.Debug.Log("Stamped build version: " + version);
return version;
}
static string RunGit(string args)
{
ProcessStartInfo psi = new ProcessStartInfo("git", args)
{
RedirectStandardOutput = true,
UseShellExecute = false
};
using Process p = Process.Start(psi);
string output = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit();
return output;
}
}
Calling VersionStamp.WriteVersionInfo(); as the very first line inside BuildAndroid() from Section 6 means every build produced by this script writes its own version string into Assets/Resources/version.txt before BuildPipeline.BuildPlayer ever runs, so that file — and the version it names — gets baked directly into the finished build. A small runtime script can then read that file back and show it somewhere on screen, like a corner of a debug menu:
using UnityEngine;
using UnityEngine.UI;
public class VersionLabel : MonoBehaviour
{
void Start()
{
TextAsset info = Resources.Load<TextAsset>("version");
string version = info != null ? info.text : "dev build";
GetComponent<Text>().text = version;
}
}
Now trace what this buys a team when something actually goes wrong:
Without this, "which build were you on" too often gets answered with "the one from Tuesday, I think," and a programmer ends up guessing at a range of commits instead of checking out one exact one — the same expensive, avoidable hunt from Section 1, just moved from "which commit broke the build" to "which commit has this bug."
Section 3's final pipeline stage was "upload it somewhere testers can install it." That somewhere matters — a signed, versioned .ipa sitting on a build server nobody can reach is not meaningfully different from no build at all. A distribution service is what actually gets a finished artifact from the pipeline onto a real tester's device, without a person manually emailing a huge file around or cabling a phone to a laptop.
At a concept level, each of these three solves the same problem for a different platform:
.ipa to App Store Connect; Apple processes it, and testers who have already been invited get a notification inside the TestFlight app and can install it directly — no cable, no manually trusting an unknown developer certificate on the device..aab) to that track; a small, pre-approved group of testers gets it through the completely ordinary Play Store app on their phone, which is useful specifically because it exercises the same install path real players will eventually use.The common thread across all three: none of them require a person to manually move a file to a tester. The upload stage of the pipeline pushes straight to wherever testers already know to look, automatically, every single time a build passes the earlier stages — which is exactly what makes it realistic to get a fresh, testable build in front of people daily, or even several times a day, instead of only before a big milestone.
Continuous deployment (CD, distinct from but built on top of CI) is the automated process of taking a build that has already passed CI and actually getting it in front of real users — not just the testers from Section 9, but the live player base of a shipped game. This needs real caution: a mistake reaching testers wastes some time; a mistake reaching every live player can cost real money, real trust, or a broken save file for people who are not testers and did not sign up to find bugs.
The standard answer is a staged rollout (sometimes called a canary release, after the historical practice of carrying a canary into a coal mine as an early warning sign): instead of shipping a new patch to every player simultaneously, release it to a small percentage first, watch real metrics — crash rate, error logs, server load, player reports — for a period of time, and only then widen it, one step at a time.
The entire value of a staged rollout is in that last block: it limits the blast radius of a mistake. Every pipeline in this section, however well built, will eventually let a real problem through — CI and tests catch most mistakes, not literally all of them. A staged rollout is not a way to avoid ever shipping a bad build; it is a way to make sure that when it inevitably happens, it affects one percent of players for a few hours instead of one hundred percent of players indefinitely.
Everything earlier in this section — caching (Section 7), splitting fast pushes from nightly builds (Section 5) — is partly a technical concern and partly something else entirely: how it feels, every single day, to be a programmer waiting on a build.
A fast build lets a programmer stay in what is usually called flow state — focused, continuous attention on one problem. Push, wait a couple of minutes, see the result, keep working. A slow build breaks that same rhythm in a very specific, costly way:
The 35-minute version does not just cost 35 minutes. Task-switching has a real cost of its own — regaining full concentration after an interruption takes measurable time beyond the interruption itself, and a programmer who has switched away from a build often does not switch back the instant it finishes. Multiply this by every programmer on a team, several times a day, across months of production, and slow builds quietly become one of the largest hidden costs on a project — not measured in a single dramatic incident, but in a team that is a little more tired, a little more scattered, and a little slower to ship, every single day.
This is exactly why this section spent real time on caching the Library folder, splitting expensive checks into a nightly job, and keeping the push-triggered pipeline as lean as it reasonably can be. None of that is about the build server's convenience — it is about protecting the fast feedback loop from Section 4 for the humans who depend on it, because a team that trusts its CI to be fast keeps using it constantly, and a team that dreads a slow build quietly starts avoiding it, which brings back exactly the "it works on my machine" risk this entire section exists to prevent.
on: block means that will never happen. What is missing, and what would you add?
name: Nightly Only
on:
schedule:
- cron: '0 2 * * *'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: dotnet build
This workflow's on: block only contains a schedule trigger, which means it runs once a day at 02:00 UTC and never in response to an actual push. A commit pushed at 9 AM would not be checked until the following night at the earliest — the exact opposite of Section 4's fast feedback loop, and much closer to the "nobody notices for days" problem from Section 1.
The fix is to add push (and usually pull_request) triggers alongside the existing schedule, since a single workflow's on: block can list more than one trigger at once:
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 2 * * *'
Now the same job runs immediately on every push or pull request (the fast lane from Section 5) and still runs on the nightly schedule too. In a real project, the nightly trigger would usually point at a separate, heavier job — the full platform matrix and long tests from Section 5 — while the push trigger stays fast and lean; here, both triggers happen to point at the same simple job, which is still a real improvement over never running until the next 2 AM.
BuildVersionString(int buildNumber, string gitHash) that returns a version string in the same style as "1.4.0+238.a3f9c21", assuming Application.version is already set to "1.4.0" in Player Settings. Then trace what it returns for buildNumber = 512 and gitHash = "7c1e0af".static string BuildVersionString(int buildNumber, string gitHash)
{
return $"{Application.version}+{buildNumber}.{gitHash}";
}
Trace: with Application.version already set to "1.4.0", calling BuildVersionString(512, "7c1e0af") substitutes each value into the interpolated string in order: Application.version becomes "1.4.0", buildNumber becomes 512, and gitHash becomes "7c1e0af", producing "1.4.0+512.7c1e0af". Following Section 8's pattern, this string would then be written into Assets/Resources/version.txt before the actual build runs, so it ends up baked into that exact artifact and readable at runtime by a script like VersionLabel.
key should include a hash of Packages/manifest.json rather than just a fixed string like "unity-library-cache". - name: Cache Library folder
uses: actions/cache@v4
with:
path: Library
key: Library-${{ runner.os }}-${{ hashFiles('Packages/manifest.json') }}
restore-keys: |
Library-${{ runner.os }}-
If the key were just a fixed string like "unity-library-cache" with no hash, the exact same cached Library folder would be restored forever, even after someone adds or updates a package in Packages/manifest.json. Unity would then be working from import data that does not know the new package exists yet, which can produce confusing missing-reference or missing-type errors that have nothing to do with the actual code change that triggered the build. Hashing the manifest file means any real dependency change automatically produces a different key, so the cache is correctly treated as invalid and Unity properly reimports whatever the new dependency requires — while every ordinary commit that does not touch dependencies still gets the fast, cheap cache hit from Section 7.