Skip to content
Sunstone Apps
← Back to blog
Expo Android release

Version, versionCode, and generated config: three numbers an Android release must align

The app was live on Play with versionCode 9, but Settings still showed 0.1.0 and lint could swap production config for development without a visible error. That is not a Gradle bug — it is a versioning contract that was never written down.

Published: 8 min read
OpenGraph preview image for this article. Version, versionCode, and generated config: three numbers an Android release must align

Three different numbers show up in every Android release of Daily Sudoku: Offline Puzzle, and each one answers a different question.

version in app.json is what players see in Settings and what the Play Store lists as versionName. versionCode is the monotonic integer Google Play requires on every upload — never reuse it. The third number is less visible: the environment baked into config.generated.ts, which decides whether the binary runs with real ads, active Sentry, store mocks, or development shortcuts.

When those three drift apart, the Console looks right and the device does not.

Why 0.1.0 stayed for nine builds

During internal testing and the first public launch, we bumped versionCode from 1 to 9 while expo.version stayed at 0.1.0. Play accepted it. Support, testers, and release notes paid the price.

Testers cannot tell they are on a new build without reading versionCode, which never appears in the UI. The welcome modal compares welcomeSeenVersion to appVersion — bumping only the build number does not reopen the update notice. And “another 0.1.0” in release notes does not say whether the ship contained fixes or features.

The rule adopted in the monorepo follows pragmatic SemVer:

  • PATCH (0.1.00.1.1): fixes and polish without a meaningful new feature.
  • MINOR (0.1.x0.2.0): a visible new capability (tutorial, extra difficulty, new mode).
  • MAJOR (0.x.x1.0.0): a product milestone, not necessarily a technical breaking change.

versionCode increments on every upload regardless of PATCH. Sentry uses com.sunstoneapps.dailysudoku@{version}+{versionCode} with dist = versionCode. For the post-board-fix candidate, the repo moved to 0.1.1 + versionCode 10.

Lint that erased production config

Another issue showed up in the release sequence, not in Gradle.

Sudoku generates src/app/config/config.generated.ts from per-environment YAML (development, test, production). Store build scripts export EXPO_PUBLIC_APP_ENV=production before pnpm --filter sudoku config:generate. The resulting file sets generatedConfigEnvironment = "production" and real AdMob, Sentry, and Firebase IDs.

The lint script also called config:generate up front, defaulting to development when the variable was not already in the shell. expo lint loads .env, but that happens after regeneration.

Sequence we hit in practice:

  1. pnpm sudoku:apk:prd left config.generated.ts on production.
  2. pnpm run lint overwrote it to development.
  3. Anyone inspecting the file thought the release candidate was wrong — and committing without noticing could package the wrong config in the next step.

The fix was to remove config:generate from lint and typecheck. Those checks do not need to mutate generated artifacts; they only validate the TypeScript already on disk. Regenerating config is limited to commands that declare environment on purpose: start, test, sudoku:apk:dev*, sudoku:apk:prd*, sudoku:aab:prd.

We also fixed .gitignore, which pointed at apps/*/src/config/config.generated.ts while the real file lives at apps/*/src/app/config/. The wrong path kept the artifact tracked while the ignore rule did nothing.

dev and prd commands: environment in the name

Old scripts mixed build speed with environment. pnpm sudoku:apk was incremental but always used production config. pnpm sudoku:apk:full added prebuild. pnpm sudoku:aab:clean forced --rerun-tasks. None of the names said “development” or “production”.

We renamed commands to make the contract explicit:

Command Environment Gradle / prebuild
sudoku:apk:dev development incremental, no prebuild
sudoku:apk:dev:full development prebuild + --rerun-tasks
sudoku:apk:prd production incremental, no prebuild
sudoku:apk:prd:full production prebuild + --rerun-tasks
sudoku:aab:prd production prebuild + bundleRelease --rerun-tasks

dev uses mocks, development shortcuts, and development YAML — useful for iterating gameplay without simulating the store. prd validates ads, Sentry, Firebase, and RevenueCat the way Play builds do.

Why we folded “clean” into “full”

There were also sudoku:apk:prd:clean and sudoku:aab:prd:clean, which only added --rerun-tasks to Gradle. The split created a real team question: “Do I use full or clean for Play?”

The historical answer lived in the cached JS bundle article: an incremental AAB shipped with versionCode=3 in the manifest while Sentry runtime still reported +2. --rerun-tasks fixed it. That is why clean existed.

The simplification was direct: every *:full and the sole aab:prd now always run --rerun-tasks. Fast paths (apk:dev / apk:prd) stay incremental for JS/TS-only changes when the Android tree already exists. The tradeoff is slower “full” builds in exchange for fewer decisions and less risk of shipping a stale bundle.

There is no incremental AAB command anymore. For Play Store uploads, only pnpm sudoku:aab:prd.

When incremental builds lie

Incremental apk:prd is fine for JS/TS-only changes — modal copy, note colors — as long as the android/ tree already exists and no native config plugin changed.

The 0.1.1 candidate included changes to plugins/withKeyEvent.cjs, which injects onKeyDown into generated MainActivity.kt. That does not land in an incremental APK: without expo prebuild, the binary keeps the old Activity and the physical keyboard never fires. Release smoke used sudoku:apk:prd:full before the AAB, and the Play guide recommends *:full whenever native plugins or Android-facing dependencies change.

The split is not ceremony. It is the gap between “Metro reloaded” and “Gradle rebuilt what players install”.

What shipped in PATCH 0.1.1

The PATCH bump was not cosmetic. It bundles fixes testers had already reported on different 0.1.0 builds with different versionCode values:

  • Mandatory game over — non-dismissable modal with explicit exits (Restart, New Game, Home) so the back button cannot drop players onto a finished board.
  • Clearer pencil marks — cell-proportional sizing in both renderers (Skia and Native), plus anti-exploit rules for manual notes.
  • Win confetti — overlay as the screen’s last child with zIndex/elevation so it does not paint under the content.
  • Android physical keyboard — digits, erase, and h/a/n shortcuts via react-native-keyevent on the Activity, not a hidden TextInput (which still opens the soft keyboard under Fabric).

Short Play release notes live in docs/app-sudoku/SUDOKU_LOJA_LISTING.md. Long-form context stays in history and this article; the store listing gets two lines a player can actually read.

Validate Sentry and Settings on device

Before promoting an internal track build, we check three signals on the production APK/AAB:

  1. Settings — line Version 0.1.1 · Build 10 (or localized equivalent).
  2. Sentry — bootstrap event with release [email protected]+10 and dist=10.
  3. Ads/consent — UMP flow and no test banner; IDs come from production YAML embedded at generate time.

If Settings shows 0.1.0 with build 10, app.json was not committed or the JS bundle came from cache — rerun *:full or aab:prd.

Checklist before shipping 0.1.1

After these changes, the Sudoku release flow looks like this:

  1. Check the highest versionCode published in Play Console (production was 9; next is 10).
  2. Set expo.version by change type (PATCH for the current fixes).
  3. Increment versionCode and align ios.buildNumber.
  4. Run pnpm run typecheck, pnpm run lint, pnpm run format:check — without regenerating config to development.
  5. Build with pnpm sudoku:aab:prd (or apk:prd:full for on-device smoke).
  6. If you still inspect the repo, confirm generatedConfigEnvironment = "production" in the generated file.
  7. Validate on device: Settings shows Version 0.1.1 · Build 10, Sentry initializes with +10, fixed flows work (game over, notes, confetti, physical keyboard).

.env does not pick the environment

apps/sudoku/.env holds secrets and vendor keys (Sentry, Firebase, RevenueCat, web ads). Do not put EXPO_PUBLIC_APP_ENV there — build scripts export the environment (apk:dev*development, apk:prd* / aab:prdproduction, E2E → test). For day-to-day Expo, pnpm sudoku already defaults to development in config:generate; for production web use pnpm sudoku:production.

What did not change

The rule against uploading AAB through browser automation still stands. The local path is apps/sudoku/android/app/build/outputs/bundle/release/app-release.aab. EN/pt-BR release notes live in docs/app-sudoku/SUDOKU_LOJA_LISTING.md.

Posts and history entries that mention sudoku:aab:clean or bare sudoku:apk record commands from their time; current operational reference is in README.md, docs/GUIA_APK.md, and docs/app-sudoku/SUDOKU_GUIA_GOOGLE_PLAY.md (Versionamento section).

A trustworthy Android release is not just incrementing versionCode. It is the name players see, the build Play indexes, the YAML baked into the binary, and the script you ran five minutes before upload all pointing at the same environment.

Freezing 0.1.0 while versionCode climbed was a pre-launch shortcut. In public production, a SemVer PATCH costs little. config:generate inside lint was worse: a silent side effect that only shows up when you open the right file in the wrong order.

If your Expo app generates per-environment config, list which npm scripts touch that file and with which EXPO_PUBLIC_APP_ENV. Lint should not be on the list.

Talk to Sunstone Apps