Every manual build starts as a five-minute job and ends as a twenty-step checklist you only half remember. This post is about moving that checklist into scripts and CI for a two-person team: which steps paid for themselves, which ones got more fragile once automated, and where we stopped on purpose.
The boring parts — version numbers, signing, getting a build onto a tester's phone — are also the parts that break releases. So that is where most of this is spent, not on YAML aesthetics.
The manual build was a checklist, and checklists rot
Our hand-run Android release looked roughly like this: pull latest, switch the platform target, flip a define, bump the version string, bump the version code, build an app bundle, wait, find the keystore, upload, write release notes, invite whoever needed to test it. About a dozen steps, most of them mechanical, two of them genuinely dangerous.
The failure mode of a checklist is not that you forget a step. It is that you remember a step wrong. A build with a stale define set is worse than no build, because it looks correct and behaves differently than the code in the repository. We shipped internal builds where the version string on the title screen did not match the bundle we uploaded, and spent longer figuring out which binary a bug report referred to than fixing the bug. That is the actual cost of manual releases: not the 30–40 minutes, but the ambiguity afterwards.
So the first rule we settled on: automate the steps whose failures are silent. Automate the steps whose failures are loud only when you have time left over.
Step one: script the build, not the pipeline
We did not start with CI. We started with one shell script on a laptop, because a script you can run locally is a script you can debug. Unity supports running the editor headless and calling a static method, which is documented in the Unity command-line interface manual.
using System;
using UnityEditor;
using UnityEditor.Build.Reporting;
public static class BuildEntry
{
public static void AndroidBundle()
{
var version = Environment.GetEnvironmentVariable("BUILD_VERSION") ?? "0.1.0";
var code = int.Parse(Environment.GetEnvironmentVariable("BUILD_CODE") ?? "1");
PlayerSettings.bundleVersion = version;
PlayerSettings.Android.bundleVersionCode = code;
EditorUserBuildSettings.buildAppBundle = true;
var options = new BuildPlayerOptions
{
scenes = new[] { "Assets/Scenes/Boot.unity", "Assets/Scenes/Main.unity" },
locationPathName = "build/android/game.aab",
target = BuildTarget.Android,
options = BuildOptions.None
};
var report = BuildPipeline.BuildPlayer(options);
EditorApplication.Exit(
report.summary.result == BuildResult.Succeeded ? 0 : 1);
}
}
And the caller:
#!/usr/bin/env bash
set -euo pipefail
export BUILD_VERSION="${1:?usage: build.sh 1.2.0}"
export BUILD_CODE="$(git rev-list --count HEAD)"
mkdir -p build/android
"$UNITY_PATH" \
-batchmode -nographics \
-projectPath "$PWD" \
-executeMethod BuildEntry.AndroidBundle \
-logFile build/unity.log
echo "built $BUILD_VERSION ($BUILD_CODE)"
Two details matter more than they look. We do not pass -quit, because the script calls EditorApplication.Exit with a real exit code — otherwise a failed build returns 0 and everything downstream cheerfully continues. And the scene list is explicit rather than read from build settings, so a scene someone unchecked locally cannot quietly disappear from a release.
This single script removed most of the silent failures. Everything after it was optimisation.
Version numbering is the one thing that must never be manual
Google Play requires each upload to use a version code greater than the current one, and the bundle must be signed with the same signature as the published version (Play Console Help). A human typing that number will eventually type it twice, and you find out at upload time, after the build.
We derive the version code from git rev-list --count HEAD: monotonic, reproducible from any checkout, no state to keep. Gaps are harmless — you only need each upload to be higher, not consecutive. The user-facing version name stays a deliberate human decision, passed as an argument, because it communicates intent and no script knows whether a change is a patch or a milestone.
Side benefit: because the version code is a commit count, a bug report with a version code is a bug report with an exact commit. That closes the ambiguity problem described above, and it is what makes remote debugging on hardware you do not own tolerable — the theme of debugging Android fragmentation without the device.
Signing: automate the invocation, keep the secrets boring
Signing is where small teams over-automate. The temptation is to push the keystore into CI secrets so that a tagged commit produces an uploadable artifact with no human in the loop. We did part of this and then pulled back.
What we automated: reading the keystore path and passwords from environment variables so no credential lives in the Unity project or in ProjectSettings. What we kept manual: the keystore itself, backed up outside the repository, with the upload key rotation story written down in plain text. A two-person team cannot absorb the loss of a signing key, and the automation that touches it is the automation least exercised — you sign a release build far less often than you compile.
The rule we use: if a step fails once a year and failing costs you the product, a human should be present.
Test distribution: the step that got more fragile once automated
Uploading to Play's internal testing track is the obvious next automation, and it is where we lost the most time for the least return. The track itself is fine — up to 100 testers per app, invited by email (Play Console Help) — but the automated path around it has more moving parts than the build: a service account, API credentials, release-notes formatting, track state, and the review delay between upload and installability.
Automated uploads failed in ways that were hard to attribute. Was the artifact bad, the credential expired, or the track in an unexpected state? Meanwhile the manual alternative — drag the bundle into the console — takes about two minutes and fails loudly.
So we split it: CI builds and uploads the artifact to storage on every tagged commit, and a human moves it to a store track. Automate producing the artifact, not publishing it. For an internal test on our own devices we skip the store entirely and install the APK directly, which turns a 20-minute round trip into seconds — useful when you are chasing performance regressions the way we describe in hunting frame time spikes with the Unity Profiler.
What we deliberately left manual
- Store listing, screenshots and release notes. Low frequency, high visibility, needs judgement.
- Production rollout. The decision to ship is not a build step.
- Content/balance data validation beyond schema checks. CI can assert a card definition parses; it cannot tell you the curve feels wrong. That is still reading, as in reading a difficulty curve without telemetry.
- Anything we had run fewer than five times by hand. You cannot script a process you have not yet understood.
So how much infrastructure should two people carry?
Our working answer is: enough that any release is reproducible from a clean checkout by either person, and no more. Concretely, that is one build script per platform, one version rule, one artifact location, and a CI job that compiles on push so a broken build is caught by the machine instead of by the other developer.
The thing that changed our economics was not CI itself but sharing the pipeline across projects — the same script shape builds more than one game, which is the same argument we made in two games, one codebase. Automation that serves one project competes with the project. Automation that serves two starts paying rent.
Three questions we now ask before automating a step:
- How often does it run? Weekly and up: script it. Quarterly: write it down instead.
- Does it fail loudly? Silent failures go first, regardless of frequency.
- Who debugs it at 1am? If the answer is "whichever of us is awake", the automation must be readable by both of us. A 40-line shell script beats a plugin nobody understands.
The goal is not a pipeline you are proud of. It is not thinking about builds on the day you ship — and being able to delete any part of it in an afternoon if it stops earning its keep.
Comments
Be the first to comment.



