Skip to content

Build1 publisher3 min readPublished

A gitignored counter shipped the wrong Android version to Play

Cyfamod moved two Expo apps off EAS and onto GitHub Actions, and found that both the upload keystore and the version counter lived outside the repository, where a fresh runner could see neither.

The Engineer · Build desk

Illustration accompanying A gitignored counter shipped the wrong Android version to Play

What happened

  • Cyfamod moved Android builds and Play Store releases off EAS and onto GitHub Actions after the free build tier kept running out mid-sprint.
  • Building locally as a substitute froze a developer's machine for twenty minutes at a time. The team stopped treating it as an option for two apps needing regular test builds.
  • The first time the AAB pipeline tried to publish, it failed for a reason that had nothing to do with GitHub Actions: Play had not been told the fingerprint of the key Expo manages.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Anyone leaving a managed build service has to inventory the state that service was holding outside version control, because the keystore and the counter are both invisible in a diff.
  • exposure The counter bug does not fail the build, it produces a signed artifact with the wrong version, so the defect reaches Play before anyone notices.
  • constraint Because the version floor is global to the app, an abandoned track from a year ago can dictate what every future release must exceed, and there is no way to route around it.
  • cost Ordering the fingerprint check before Gradle decides whether a signing mismatch costs seconds or forty minutes of runner time per attempt.

Both incidents share a property. The managed service was holding state that never lived in the repository. Expo generates and manages the upload keystore by default [5]. The version counter sat in a gitignored local file [9]. You will not find either in a diff, and a fresh GitHub Actions runner does not have them.

The signing failure is the well-behaved one. Google Play needs the SHA-1 fingerprint of whatever key signs production uploads registered in advance, and if the signature on the AAB matches nothing Play has been told to trust, Play rejects an otherwise valid build [6]. Cyfamod pulled the real upload-key fingerprint with `eas credentials` and pinned it as an expected value the workflow checks before it spends time on a Gradle build [7]. That ordering is correct engineering: the cheap assertion runs first, so a mismatch no longer costs a wasted 40-minute build [8]. Forty minutes is their number on their project. For it to transfer you need a comparable Expo React Native codebase and comparable runners.

The counter failure produces an artifact instead. The file it read was gitignored, so it did not survive a fresh CI runner, and the pipeline silently shipped v1.0.1-b2 when the intended release was several versions ahead [9]. Play's rejection came back in terms of upgrade eligibility: "You cannot rollout this release because it does not allow any existing users to upgrade to the newly added APKs" [10].

Recovery is where the real constraint appears. `versionCode` must strictly increase per app across every track, not per track [11]. An old, forgotten upload on an unrelated track sets the floor, and blocks every future release until you exceed it [12].

Rebuilding the counter from the Play Console does not work either. A release displayed as "(9)" had an enforced `versionCode` of 6, because the display name is human-typed and Play does not cross-check it against the number it enforces [13]. That is a gap of three between what the console shows and what the API will accept [14].

The reason for the move was capacity. The free tier queue told them, "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" [4], and building locally instead froze a developer's machine for twenty minutes at a time [3]. Replacing a managed build service with your own pipeline means you stop waiting on a queue and start shipping wrong artifacts, and the two apps here ship from one Expo codebase switched by a route env var at build time, so a versioning bug has two Play listings to land in [1]. Cyfamod says later parts of the series cover a GitHub Actions storage-quota crisis and what it took to fix for good [15].

What to watch

  • Whether Part 2 shows the counter replaced by a value read back from Play or one derived from git history.
  • Whether the pinned fingerprint preflight survives an upload-key rotation, when the expected value goes stale.
  • What the Actions storage-quota crisis in Parts 3 and 4 costs, and whether it changes the case for leaving EAS.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories