TL;DR: The dangerous state on a Unity project is not a broken build. It is a working build that exactly one machine can produce. I have shipped Unity to Windows, Android, iOS and standalone headsets, run CI/CD for a C# SDK at Geonode, and managed App Store and Play Console releases as CTO at RAQTS. The order that actually matters is: build somewhere nobody uses, make every artifact identify itself, get the signing keys off laptops, automate delivery to testers, and only then chase test automation. Most teams do that list backwards.
Every Unity project I have joined mid-flight has had the same quiet problem, and nobody lists it as a problem. Builds happen on one person's laptop. That person has the right Unity version, the right Android SDK, the right provisioning profile, a keystore in a folder they would struggle to find again, and eight months of small manual fixes living only in their memory.
It works, right up until they are on a flight, or the client wants a build on a Friday night, or they leave.
This is not a Unity-specific failure, but Unity makes it much worse than a web project does, and that is worth being specific about before talking about fixes.
Why Unity is harder to put on CI than a web app
A Next.js app on CI is a checkout, an install and a build. Unity has a longer list of things that are true only on one machine.
The editor is a licensed application, so your build agent has to activate a licence and release it again, which is a genuine source of flaky pipeline failures rather than an afterthought. The Library folder is a multi-gigabyte derived cache that takes tens of minutes to regenerate from scratch, so a cold build and a warm build are different animals. Asset import is not fully deterministic across editor versions, which means "same commit, different Unity patch release" is a real category of bug. And the platform toolchains sit underneath all of it: an Android SDK and NDK pinned to a version Unity is happy with, an Xcode version Apple has not yet deprecated, and a Windows toolchain for anything desktop.
At Geonode I built the Repocket C# SDK for Windows, Android and iOS and set up Unity and Windows CI/CD around it. The lesson there was blunt: three platforms compiling on one developer's machine is not three platforms working. Until every target builds on a machine that has never been touched by hand, you do not know what your dependencies actually are. You only know what your laptop happens to already have.
That is the real first win from CI, and it has nothing to do with tests.
Step one: build on a machine nobody uses
The first pipeline you write should do one thing. Check out the commit, build the player, and put the artifact somewhere with a name that includes the commit hash.
No tests, no distribution, no matrix of platforms. Just prove a clean machine can produce a build.
The value is immediate, because that pipeline is a written, executing description of your build requirements. Every undocumented step gets flushed out in the first week: the SDK version nobody wrote down, the plugin that was manually copied into Plugins, the define symbol somebody set in the editor and never committed, the API key that lives in a local file. Each of those is a landmine sitting under your project, and CI walks over all of them in a few minutes.
Once that passes, the "works on my machine" class of problem is closed permanently. That single guarantee is worth more than any test suite you will write in the first six months.
Step two: make every build say what it is
This is the cheapest high-value thing on the list and it gets skipped constantly.
Every build should carry an identity you can read without a debugger: a version, a build number that increments on its own, and the short commit hash. Put it on a debug screen, in the log, in the settings corner, wherever it can be seen in ten seconds. Then have CI stamp it automatically, because a version number a human edits is a version number that is sometimes wrong.
I learned to care about this in XR rather than in a normal app. When you are demoing an AR platform in the field, or running a clinical eye-tracking session on a Vive Focus 3, someone will report behaviour you cannot reproduce. The only question that matters is which build they were holding, and standalone headsets pass between hands with no install history a developer can see. Without an on-screen hash, you are reasoning about a device that could be running any of the last six builds. With it, the report becomes a specific commit and the investigation takes minutes.
The same rule holds on a phone. A bug report against "the TestFlight build" is a guess. A bug report against 1.4.2 (318) a91c40e is evidence.
Step three: signing off laptops
Keystores, provisioning profiles and API keys should live in your CI secret store, and the pipeline should be the only thing that touches them for release builds.
The obvious reason is availability. If exactly one machine can sign an Android release, then that machine is a single point of failure for your ability to ship at all, and an Android keystore is not something you can regenerate. Lose it and you cannot update the app under the same listing.
The less obvious reason is that it forces a boundary you want anyway. Once signing is a CI job, "who can publish" becomes a permission rather than a fact about who has which files on which laptop. That is the same boundary I keep on voice agent config, where one person publishes and the config lives in git. Ownership of the release action should be deliberate, not accidental.
Step four: delivery, not just building
An artifact in a CI storage bucket is not a release. The pipeline is not done until the build reaches the people who need to look at it: TestFlight or a Play internal track for mobile, a signed installer or an internal share for desktop, direct install for headsets.
The reason to automate this specifically is cycle time on feedback. When getting a build to a client is a fifteen-minute manual chore, it happens once a week and every review round costs a day. When it happens on merge, the client sees Tuesday's work on Tuesday. On the RAQTS side, where a Unity interactive platform, mobile apps and a web portal all had to move together, that gap is what decides whether a release is one coordinated thing or three things that drift.
Step five, and only now: tests
I am deliberately putting automated testing last, which is not the usual advice.
In Unity specifically, the highest-value automated checks are narrow. Compile every target, so a change that breaks the iOS build is caught on the pull request rather than on release day. Unit test the pure C# where the real logic lives, which is exactly the discipline of keeping a shared core free of platform knowledge. Both of those are cheap and pay every week.
Full play-mode automation is a much bigger investment with much slower returns, and I would not reach for it before the four steps above exist. A team that can build reliably, identify every artifact, sign from CI and deliver automatically is in far better shape than a team with a test suite and one laptop that knows how to make a build.
What I do not automate
Not every branch gets a full build. Unity builds are slow and, on hosted runners, not free. Compile checks on pull requests, full multi-platform builds on the main branch and on tags. That is enough.
I do not run iOS builds anywhere but on a machine I control the Xcode version on. And I keep the Library cache warm between runs, because a cold build agent is a twenty-minute tax on every job, while also running one scheduled clean build so cache drift never becomes an invisible dependency.
The store is a scheduling constraint, not a deploy step
The part of mobile release engineering that surprises web-first teams is that publishing is not the end of a pipeline. It is a queue owned by someone else.
Review takes as long as it takes. Once a build is live you cannot hot-fix it, and users update on their own schedule, which means old client versions keep talking to your backend long after you have moved on. That single fact drives most of the design discipline around it: staged rollouts so a bad build reaches one percent rather than everyone, a backend that stays kind to older clients, and a rehearsed rollback that is one action rather than an improvisation.
That release discipline is the piece I have carried furthest outside of Unity. It is exactly how I ship changes to live voice agents now, where there is no store review but the same underlying truth applies: the change is live in front of real users, so it goes out small, into the quietest traffic, with a reversal you have already practised.
The test to run today
Ask whether anyone on the team other than the usual person could produce a signed, shippable build today, from a clean checkout, without asking them a question.
If the answer is no, that is the work. Not tests, not coverage, not a nicer branching model. A build one machine can make is a project with a single point of failure standing right in the middle of it, and it looks completely healthy until the day it does not.
The through line from a Unity project to an AI agent in production is the same one every time: the interesting engineering is rarely the thing users see. It is whether you can change it safely, know exactly what you shipped, and put it back.
I build cross-platform products and lead engineering as a fractional CTO, from Unity, XR and mobile through to AI agents and real-time systems. If your team is shipping something that has to hold up in front of real users, more about my background here, or book a call.