Two branches of a React Native app both uploaded build 57 and the store rejected one; how should CI assign Android versionCode and the iOS build number instead?
answer
- one counter, owned by the pipeline
- never hand-edit in build.gradle
- Gradle property read in defaultConfig
- CURRENT_PROJECT_VERSION feeds CFBundleVersion
- EAS remote version source, autoIncrement
basics
~20 sLet one release pipeline own a single increasing counter and inject it at build time: a Gradle property for versionCode and the CURRENT_PROJECT_VERSION build setting for the iOS build number, instead of numbers edited by hand in the repository.
solid answer
~40 sBuild numbers collide when they live in the repository and people bump them by hand: two branches bump from the same base and both produce 57. The store accepts each number once, so the fix is a single source of truth owned by the pipeline. Only the release pipeline assigns numbers, from a counter that only ever increases, such as the CI run number plus an offset above the highest shipped build. It injects the value at build time: `android/app/build.gradle` reads a project property for `versionCode`, and `xcodebuild` receives `CURRENT_PROJECT_VERSION`, which the React Native template's `Info.plist` maps to `CFBundleVersion`. The marketing versions stay in the repository. Expo projects that build on EAS can instead set `cli.appVersionSource` to `remote` with `autoIncrement` on the production profile.
code
bash · 9 linesBUILD_NUMBER=$((CI_RUN_NUMBER + 1000))
# Android: Gradle project property read by defaultConfig
(cd android && ./gradlew bundleRelease -PGYM_VERSION_CODE="$BUILD_NUMBER")
# iOS: build setting that Info.plist maps to CFBundleVersion
(cd ios && xcodebuild -workspace GymBooking.xcworkspace -scheme GymBooking \
-configuration Release -archivePath build/GymBooking.xcarchive \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" archive)go deeper
Recall that each store upload needs a new, higher build number, separate from the version users see.
Explain how CI injects the number: a Gradle property read in defaultConfig, and CURRENT_PROJECT_VERSION passed to xcodebuild, which Info.plist maps to CFBundleVersion.
Show how you prevent collisions: a single release pipeline owning a monotonic counter with an offset, serialised release runs, and a mapping from build number back to commit.
Decide where version truth lives across many apps and platforms: pipeline counters, store queries or a hosted builder's remote versioning, and how that survives a CI migration.
## Why two builds end up with the same number Each React Native platform carries two versions: a **user-visible version** (`versionName`, `CFBundleShortVersionString`) and a **build number** the stores use to order uploads (`versionCode`, `CFBundleVersion`). The template hard-codes both build numbers: `versionCode 1` in `android/app/build.gradle` and `CURRENT_PROJECT_VERSION = 1` in the Xcode project, with the React Native template's `Info.plist` setting `CFBundleVersion` to `$(CURRENT_PROJECT_VERSION)`. If developers bump those numbers in commits, two branches that start from build 56 both commit 57. Google Play refuses an upload whose `versionCode` it has already received, and App Store Connect refuses a build number already used for that version, so whichever build uploads second is rejected, usually late in a release. ## The rule: one owner, one increasing counter 1. **Only the release pipeline assigns build numbers.** Feature-branch builds can use a fixed placeholder, because they never reach a store. 2. **The counter only increases.** The CI run number of the release pipeline is a good source; add a fixed **offset** so the first CI build lands above the highest build already shipped. 3. **The number is injected at build time**, not committed back. Committing the bump creates a new commit that can trigger the pipeline again and races with other merges. 4. **The user-visible version stays in the repository**, changed deliberately in a release commit. ## Injecting the number on each platform On **Android**, read an optional Gradle project property with a local fallback: ```groovy def ciVersionCode = project.findProperty('GYM_VERSION_CODE') android { defaultConfig { versionCode ciVersionCode ? ciVersionCode.toInteger() : 1 versionName "2.4.0" } } ``` CI passes the value as `-PGYM_VERSION_CODE=1234` or as the environment variable `ORG_GRADLE_PROJECT_GYM_VERSION_CODE`. On **iOS**, pass the build setting to `xcodebuild`. Because `Info.plist` reads `$(CURRENT_PROJECT_VERSION)`, overriding the setting on the command line changes `CFBundleVersion` without editing the project file. ## Choosing the counter | Source | Strength | Watch out for | |---|---|---| | CI run number + offset | simple, monotonic within one pipeline | resets if the pipeline is renamed or migrated; re-base the offset | | Latest store build + 1 | always above what the store has | needs store API access; two parallel builds can still read the same value | | Timestamp | no shared state | Android caps `versionCode` at 2100000000, so a minute-resolution timestamp overflows | | EAS remote versioning | Expo tracks and increments per app | only for builds that EAS runs | For the gym-booking app, a single release pipeline on `main` using its run number plus an offset is usually enough; serialise release runs so two cannot build at once. ## The Expo variant When the build runs on EAS, `eas.json` can make Expo's servers the source of truth: set `cli.appVersionSource` to `remote` and `autoIncrement` to `true` on the production build profile, and EAS increments `android.versionCode` and `ios.buildNumber` for each build. With `appVersionSource` set to `local`, the numbers come from the project, as in a bare app. ## Checks worth adding - Print the injected numbers in the build log and fail if the value is missing on the release pipeline. - Record which commit produced which build number, so a crash report's build number maps back to source. - Keep Android and iOS on the **same counter** when possible; support conversations are easier when one number names one build. - Stamp the number where the running app can show it, for example on a settings or about screen, so testers report builds unambiguously. ## Recovering from a collision already in flight When a store has already rejected a duplicate, do not edit the rejected branch's files to pick a new number by hand; that is how the collision started. Instead: 1. Re-run the release pipeline for the intended commit, so it draws the next counter value. 2. If the counter itself fell behind (for example after moving CI systems), raise the **offset** once, above the highest build the stores have seen on either platform, and record why in the pipeline configuration. 3. Make feature-branch pipelines unable to upload, so only one pipeline can ever consume numbers.
- Why not have the pipeline commit the bumped build number back to the React Native repository?The commit is a new push that can retrigger the pipeline, and it races with other merges to the same file, which recreates the collision. Injecting the number at build time keeps the repository free of per-build churn.
- What changes for an Expo app that builds on EAS?Set `cli.appVersionSource` to `remote` in `eas.json` and `autoIncrement: true` on the production profile; EAS then stores and increments `android.versionCode` and `ios.buildNumber` itself, so the CI job does not compute a number.
saying these in an interview costs you the question
- Developers should bump versionCode in each feature branch
- The user-visible version must change on every CI build
- A timestamp is always a safe versionCode
- Committing the bump from CI keeps everything in sync
- Feature-branch builds need real store build numbers