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

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: - EAS's free build tier ran out - building locally on a laptop instead doesn't scale either. - 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 meantv1.1.1-b10 . - 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. - "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. - success() andfailure() only work inside anif: condition in GitHub Actions. Use them anywhere else and your entire workflow file becomes invalid - not just the step you wrote it in. - "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. 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. 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 .gitignore d, 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 ourdocs/releases/ / 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; 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 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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.