Part 2 of a 5-part series on automating a multi-app Android release pipeline. Part 1 → covered the setup, a signing-fingerprint error, and a version number that was quietly drifting from reality. This part picks up right after versioning was fixed. Part 3 → and Part 4 → cover a real Actions storage-quota crisis and what it took to fix it for good.
TL;DR
With version numbers finally trustworthy, one of our two apps started shipping cleanly. The other kept failing with a message that had nothing to do with its actual cause. Along the way we also hit a GitHub Actions bug so quiet it doesn't produce an error — it just makes your entire workflow file stop running, silently — and made a structural call that "code is production-ready" and "ship this specific app to the Play Store" needed to be two separate decisions, not one automatic consequence of the other.
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 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 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. The response, shape preserved but values genericized:
[diagnostic] all tracks for the staff app — "production": []
[diagnostic] all tracks for the staff app — "beta": []
[diagnostic] all tracks for the staff app — "alpha": []
[diagnostic] all tracks for the staff app — "internal": []
[diagnostic] all tracks for the staff app — "<our real internal testing track>": [
{ "name": "<release label>", "versionCodes": ["<real versionCode>"], "status": "completed" }
]
Four of Google's standard named tracks — production, beta, alpha, internal — all completely empty, never used. And a fifth, custom-named track we'd created ourselves, 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 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 chat 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' }}"
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)
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"
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 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')
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.
Lessons From Parts 1 and 2
| What broke | What we learned |
|---|---|
| Local version-state file, 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 label didn't match the real versionCode | 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 Part 2 Leaves Off
Both apps now released independently, on demand, with the actual versionCode and track resolved from Google Play itself before every build. Landing code on main was safe by default; shipping to the Play Store was a deliberate, scoped action every time. It felt, at this point, like the pipeline was actually finished.
It wasn't. A few weeks later, this exact pipeline quietly filled up an entire year's worth of storage quota in about ten days — and the fix wasn't "clean up some files," it was rethinking what GitHub Actions should and shouldn't be trusted to run at all.
Next: Part 3 → — the storage-quota wall, and why the real fix was a self-hosted runner, not a cleanup script.
I write about the debugging journeys nobody puts in the docs — more at cycy.is-a.dev.
Top comments (0)