DEV Community

Cover image for From Manual APKs to Automated Play Store Releases: Every Mistake We Made Shipping Two Android Apps From One Codebase
cynthia wahome
cynthia wahome

Posted on

From Manual APKs to Automated Play Store Releases: Every Mistake We Made Shipping Two Android Apps From One Codebase

A complete debugging journey through automating a multi-app Android release pipeline — from local builds burning out a free tier, to a Google Play error message that was actively lying to us about its own cause.

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 actual goal was to move Android builds and Play Store releases off EAS entirely and onto GitHub Actions, where we already had free CI minutes. Along the way:

  1. EAS's free build tier ran out — building locally on a laptop instead doesn't scale either.
  2. A gitignored local counter decided our version numbers — it doesn't survive a fresh CI runner, so we silently shipped v1.0.1-b2 when we meant v1.1.1-b10.
  3. Google Play's versionCode must 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.
  4. "Release in track targeting no countries" was not a countries problem. It took us three wrong theories to find out our pipeline was quietly publishing to an empty, unused Play track — while the real one, with real testers, sat one dropdown away.
  5. success() and failure() only work inside an if: condition in GitHub Actions. Use them anywhere else and your entire workflow file becomes invalid — not just the step you wrote it in.
  6. "Code merged to main" and "release this app to the Play Store" are two different decisions. Wiring them together meant one app's fix forced a pointless rebuild — and a resubmission mid-review — of an app that had already shipped fine.

If any of those six sentences made you wince in recognition, keep reading. (If you just want the finished, genericized workflow, skip straight to the gist — full YAML plus every required secret and variable, no signup required.)

🚨 The Setup: Two Apps, One Codebase, One Free-Tier Ceiling

Cyfamod-SMS is 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 an EXPO_PUBLIC_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.
Enter fullscreen mode Exit fullscreen mode

Running the build locally instead worked, but:

Running these builds locally takes a lot from my pc

A laptop is not a build farm. 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 already had free CI minutes and weren't rationed by a build queue. Everything in this post — the signing, the versioning, the tracks, the triggers — 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 idea, roughly:

I need it automated in a way that when someone pushes to the dev branch, the pipeline will build into an APK file and upload it to S3, then the links to both APK files (staff and student) from S3 will be sent to our build channel automatically.

That became the shape of the whole system going forward:

Branch What happens
dev Build APKs for both apps, upload to S3, post download links to Discord — 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 that came back wasn't about GitHub Actions at all:

You need to get the fingerprint from Expo using EAS before you build the AAB.

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.
Enter fullscreen mode Exit fullscreen mode

The build that failed this way was tagged v1.0.1-b2. The release we'd actually intended to ship was v1.2.1-b10 — a completely different version, several minor releases ahead.

Here's what was happening: the build script tracked the current version and build number in a JSON file on disk — local-builds/.version-state.json — incrementing it on every build. That file was .gitignored, which is 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 v1.0.1-b2 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 (1.2.1) now comes from the highest-numbered file already committed in our docs/releases/<app>/ folder — release 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 the counter last ran.
  • The build number (versionCode) — this is the one that actually matters to Google, and it's the one with a much sharper rule underneath it.

❓ Why Does Google Play Reject My 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 versionCode 12 for your package, Google Play will reject any new upload to any track — your production track, a fresh closed-testing track, doesn't matter — with versionCode ≤ 12. The number is global to the app. It is 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;
Enter fullscreen mode Exit fullscreen mode

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.

🕵️ Why This Was Sneaky: 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 "Student v1.2.0 (9)" — implying versionCode 9. The actual enforced versionCode on that release, queried directly from the API, was 6.

The (9) is a free-text release name — a label 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 says 9, so ship 10," 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.

🎯 Issue #3: The Play Store Error That Was Lying About Its Cause

With versioning fixed, the student app shipped cleanly. The staff app kept failing — always the same message:

##[error]Release in track targeting no countries
Enter fullscreen mode Exit fullscreen mode

This is where we burned the most time, because every theory we had was plausible and wrong.

Theory 1: the closed-testing track just needs its countries selected. Checked Play Console — countries were already set, and had been from the very first release.

Theory 2: maybe it's set for one app's track but not the other's, since only staff was failing. Also wrong — nothing had been touched differently between the two apps' track settings.

Theory 3: the API field for a track's country availability (countryTargeting) must only be settable for certain release states. Also a dead end — that field isn't even writable through Google's own API for anything other than the production track.

The actual answer only showed up once we stopped guessing and asked Play directly for every track the staff app had, not just the one our pipeline was configured to publish to:

[diagnostic] com.cyfamod.staff all tracks  "production": []
[diagnostic] com.cyfamod.staff all tracks  "beta": []
[diagnostic] com.cyfamod.staff all tracks  "alpha": []
[diagnostic] com.cyfamod.staff all tracks  "internal": []
[diagnostic] com.cyfamod.staff all tracks  "Cyfamod SMS Staff Beta": [
  { "name": "v1.2.0 (9) - Result PINs nav + freshness fixes", "versionCodes": ["12"], "status": "completed" }
]
Enter fullscreen mode Exit fullscreen mode

Four tracks — production, beta, alpha, internal — all completely empty, never used. And a fifth, custom-named track, Cyfamod SMS Staff Beta, holding the actual live release with real testers and real country availability.

Our pipeline was configured to publish to alpha. That track had never had a release, never had countries configured, because nobody had ever actually used it. The countries were never missing — we were shipping to the wrong track entirely, and Google's error message described the empty track's state accurately; it just never occurred to us to question which track it was talking about.

The student app never had this problem because its real, active track happened to literally be named alpha. One coincidence of naming was the only reason half our releases worked and the other half didn't.

The fix: a single shared "which track do we publish to" setting can't survive two apps that don't happen to share a track name. We split it into one variable per app (PLAY_STORE_TRACK_STAFF, PLAY_STORE_TRACK_STUDENT) instead of one value everyone assumed would fit.

🧨 Issue #4: The One-Line Bug That Invalidated an Entire Workflow

Fixing "which app failed" reporting in our Discord notifications introduced its own bug — one worth calling out because of how completely it hid itself.

We wanted to record, per app, whether that specific release succeeded or failed, so a mixed result (one app ships, one doesn't) wouldn't read as a blanket failure. The first attempt looked reasonable:

- name: Record release result
  if: always()
  run: |
    result="${{ failure() && 'failure' || 'success' }}"
Enter fullscreen mode Exit fullscreen mode

That workflow run showed zero jobs. Not a failed job — no jobs at all, and the run's name fell back to the literal file path instead of the workflow's actual name:. That's GitHub's specific signature for "this workflow file could not be parsed," and generic YAML validation (yaml.safe_load() in Python, or any plain YAML linter) will tell you the file is completely fine, because it is — as YAML.

success(), failure(), cancelled(), and always() are only valid inside an if: condition in GitHub Actions' expression syntax. Use them anywhere else — including inside a run: command, even wrapped in ${{ }} — and the entire workflow file is invalid, not just the step containing the mistake.

The tool that actually catches this is actionlint, which understands GitHub Actions' schema and expression rules specifically — not a generic YAML parser:

$ actionlint .github/workflows/release-android.yml
# (zero errors, after switching to two if:-gated steps instead)
Enter fullscreen mode Exit fullscreen mode

The fix is the standard pattern for exactly this situation:

- name: Record release result — success
  if: success()
  run: echo "ROUTE_RESULT=success" >> "$GITHUB_ENV"

- name: Record release result — failure
  if: failure()
  run: echo "ROUTE_RESULT=failure" >> "$GITHUB_ENV"
Enter fullscreen mode Exit fullscreen mode

🚦 Issue #5: One Merge, Two Apps, Zero Control

The last one wasn't a bug — it was a design decision we'd made early and outgrown. Merging a PR into main triggered a full build-and-publish of both apps, unconditionally, every time.

That was fine when both apps usually shipped together. It stopped being fine the moment a fix was scoped to just one of them. Landing the staff-only track fix meant main's matrix build would rebuild student too — an app that had already succeeded and was sitting in Play Console under review. Resubmitting it wouldn't just waste 45 minutes of CI time; it would reset a review clock that was already ticking down, for a change that had nothing to do with student at all.

The fix was to stop treating "code is production-ready" and "publish this app to the Play Store" as the same event:

# Every pull_request event is preflight-only now — secrets, signing,
# Play-track diagnostics — regardless of branch, regardless of merged status.
# A real build+publish only ever happens via an explicit workflow_dispatch.
if: >-
  github.event_name == 'workflow_dispatch' ||
  (github.event_name == 'pull_request' && github.event.action != 'closed')
Enter fullscreen mode Exit fullscreen mode

main is still the production branch — merging to it still means "this is ready." It no longer also means "and therefore publish every app to Google Play right now." Shipping a specific app became a deliberate action: pick the branch, pick the app, run the workflow. Adding a third app later means adding a third option to that same dropdown — not rewriting the release logic.

I genericized the actual workflow file (package names, secret names, and track names swapped for placeholders — the structure and every comment explaining why are real) into a gist if you want the full shape of it: per-app Android release pipeline template →

🧾 Lessons Learned

What broke What we learned
Local .version-state.json, gitignored Never trust local/ephemeral state for anything CI needs to stay in sync with an external system — query the system directly
versionCode rejected across an unrelated track Google Play's version rule is per-app, across every track — not scoped to the one you're looking at
Play Console's release name said "(9)", real versionCode was 6 A human-readable label is never the same thing as the field a platform actually enforces
"No countries" error, countries were already set When a platform's error message doesn't match reality, get the complete picture (every track) before trusting any one theory
failure() used outside if: Some expressions are context-restricted; a generic syntax check won't catch a schema violation — use a tool that understands the platform's own rules
One merge auto-released two apps "Code is integrated" and "ship this specific thing" are different decisions the moment you have more than one independently-releasable unit

🚀 Where It Stands Now

Both apps release independently, on demand, with the actual versionCode and track resolved from Google Play itself before every build — never from a counter or a label that could quietly drift from reality. Landing code on main is safe by default; shipping to the Play Store is a deliberate, scoped action every time. Failures for one app no longer masquerade as failures for both in our Discord channel, and a workflow file that can't be parsed shows up as a fast, obvious signal instead of a silent no-op.

None of this was obvious going in. Most of it wasn't obvious after the first fix either — three of these six issues required a wrong theory (sometimes two) before the real cause showed up. If you're building something similar and any of these error messages looked familiar on the way in, hopefully this saved you a few of the wrong turns.

The full, genericized workflow — every step, every gotcha commented inline, plus a complete required-secrets-and-variables checklist — is in this gist: per-app Android release pipeline template.


The mobile apps discussed here are closed-source, but the backend and web frontend they talk to are public — school-be-laravel (Laravel 11, multi-tenant) and school-fe-nextjs (Next.js 15) power the same Cyfamod School Management System, alongside school-public-web for the public-facing school sites. If you're curious how the pieces fit together, or want to contribute, they're open for it. More on what we build at cyfamod.com.

Hit a version of any of these? I'd genuinely like to hear which theory you tried first — drop it in the comments.

I write about the debugging journeys nobody puts in the docs — more at cycy.is-a.dev.

Top comments (0)