Part 1 of a 5-part series on automating a multi-app Android release pipeline — from a free-tier build queue to a genuine self-hosted infrastructure migration. This part covers the setup and the first two real incidents. Part 2 → covers a Play Store error that lied about its own cause, a one-line bug that invalidated an entire workflow file, and separating "merged" from "released." Part 3 → and Part 4 → cover a real Actions storage-quota crisis and what it took to fix it for good.
TL;DR
We ship two separate Android apps (staff and student/parent) from one Expo React Native codebase, distinguished by an env var at build time. EAS's free build tier kept running out mid-sprint, and running builds locally instead was frying a developer's laptop for twenty minutes at a time — so the goal was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions. Two things nearly derailed that before the pipeline shipped anything real:
- EAS's free build tier ran out, and building locally doesn't scale as a substitute.
-
A gitignored local counter decided our version numbers — it doesn't survive a fresh CI runner, so we silently shipped
v1.0.1-b2when we meant a release several versions ahead. -
Google Play's
versionCodemust strictly increase per app, across every track — not per track. An old, forgotten upload on an unrelated track can block every future release until you exceed it. -
Play Console's display name isn't the real number. A release literally labeled "(9)" had an actual enforced
versionCodeof 6 — a human-typed label, not something Play cross-checks against reality.
If any of that made you wince in recognition, keep reading.
The Setup: Two Apps, One Codebase, One Free-Tier Ceiling
Cyfamod builds a school management platform. Two of its Android apps — one for staff, one for students and parents — ship from a single Expo React Native codebase, switched at build time by a route env var. Same components, same hooks, same infra. Two separate .apk/.aab outputs, two separate Play Store listings.
We started the obvious way: eas build. It's a great service, right up until the free tier's queue tells you this:
I'm trying to create a new build, but since we're on the free tier,
I'll have to wait for an available worker.
Running the build locally instead worked, but running these builds locally takes a lot from a laptop — every local build meant a frozen machine and a developer who couldn't do anything else for twenty minutes. That's not sustainable for two apps that both need regular internal testing builds and eventual Play Store releases.
That ceiling is the entire reason this pipeline exists. The goal from day one wasn't "add CI on top of EAS" — it was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions, where we weren't rationed by a build queue. Everything in this series — the signing, the versioning, the tracks, the triggers, and eventually a real infrastructure migration — is what it actually takes to replace a managed build service with your own pipeline once you commit to that move.
Attempt #1: Internal Testing, Automated
The first real automation target wasn't even the Play Store — it was just getting a testable build in front of the team without anyone's laptop catching fire. The shape that emerged:
| Branch | What happens |
|---|---|
dev |
Build APKs for both apps, upload to cloud storage, post download links to the team's chat automatically — internal testing only |
main |
Build signed AABs and release to the Google Play Store |
Simple on paper. Getting there took several real production incidents, in this order.
Issue #1: The Signing Fingerprint Nobody Mentioned Up Front
The first time the AAB pipeline actually tried to publish, it failed — and the fix wasn't about GitHub Actions at all. Expo generates and manages your upload keystore for you by default. Google Play, however, needs to know the SHA-1 fingerprint of whatever key signs your production uploads, in advance, to accept them. Skip that step and your carefully automated pipeline can build a perfectly valid AAB — that Play will reject anyway, because the signature attached to it doesn't match anything Play has been told to trust.
Once we pulled the real upload-key fingerprint via eas credentials and pinned it as an expected value the workflow checks before ever spending time on a Gradle build, this class of failure became a fast, cheap preflight check instead of a wasted 40-minute build.
Issue #2: The Version Number Nobody Could Trust
With signing sorted, releases started reaching the actual Play publish step — and immediately hit a wall neither of us expected:
##[error]You cannot rollout this release because it does not allow
any existing users to upgrade to the newly added APKs.
The build that failed this way was several minor versions behind the release we'd actually intended to ship. Here's what was happening: the build script tracked the current version and build number in a JSON file on disk, incrementing it on every build. That file was .gitignored — correct for a build artifact, except we were also using it as the source of truth for what version to ship next. A fresh GitHub Actions runner has no memory of any previous run. Every real release started that counter over from scratch, confidently building an old version number while genuinely believing it was doing the right thing.
The fix had two parts, because there were actually two numbers being tracked, and neither belonged where it was living:
- The version name now comes from the highest-numbered file already committed in our release-notes folder — notes we write by hand anyway, so the version they're named for becomes the source of truth instead of a side effect of when a counter last ran.
-
The build number (
versionCode) — the one that actually matters to Google, and the one with a much sharper rule underneath it.
Why Google Play Rejects an App With "Cannot Roll Out" or "Does Not Allow Existing Users to Upgrade"
If you've landed here from a search engine: this error means Google Play has already seen a higher versionCode for your package than the one you're currently trying to upload — and it will not let a newer release ship with a lower or equal number, because that would look like a downgrade to users already on the higher version.
The part that catches people off guard: this rule applies per app, across every track — not per track. If your internal testing track, or an old one-off upload, or even a build someone did for a pentest, ever used a given versionCode for your package, Google Play will reject any new upload to any track — production, a fresh closed-testing track, doesn't matter — with an equal or lower number. The number is global to the app, not scoped to whichever track's dropdown you happen to be looking at.
We fixed this by never trusting a local counter (or a human-typed release name — more on that below) again. Before every real build, the pipeline now asks Google directly:
const edit = await androidpublisher.edits.insert({ packageName });
const { data } = await androidpublisher.edits.tracks.list({ packageName, editId: edit.data.id });
const versionCodes = (data.tracks ?? []).flatMap((track) =>
(track.releases ?? []).flatMap((release) =>
(release.versionCodes ?? []).map(Number),
),
);
const highestKnownVersionCode = versionCodes.length ? Math.max(...versionCodes) : 0;
That single query, across every track the app has, is the only reliable floor for "what number comes next." Nothing else — not our own records, not a display name in the Play Console UI — can be trusted, which brings us to the next surprise.
Play Console's Display Name Isn't the Real Number
While debugging the versionCode issue, we found something that would have derailed us if we'd trusted it: Play Console showed a release literally labeled with a build number in its name — implying that number was the real versionCode. The actual enforced versionCode on that release, queried directly from the API, was a different, lower number.
The label is free-text — a name a human typed in, completely decoupled from the number Google actually enforces. It's not malicious, it's just how Play Console's UI works: the name field is yours to fill in however you like, and Play never cross-checks it against the real version. If we'd built our fix around "the console label says N, so ship N+1," we'd have hit the exact same rollout rejection all over again, just at a different number.
Rule of thumb: if a number matters to an automated pipeline, query the field that's actually enforced. Never infer it from a label a human wrote for other humans.
Where Part 1 Leaves Off
Version numbers were now trustworthy — resolved straight from Google's own records, every time, no local state involved. That fixed what number a release would ship as. It didn't yet fix where it would ship — which turned out to be its own, much stranger problem.
Next: Part 2 → — a Play Store error message that was actively lying about its own cause, a one-line GitHub Actions bug that invalidated an entire workflow file, and why "code merged to main" and "release this app to the Play Store" had to become two different decisions.
I write about the debugging journeys nobody puts in the docs — more at cycy.is-a.dev.
Top comments (0)