Blog · October 13, 2026

Why our Android build 258 had to ship as 2258.

In August we opened the 1.2.0 release of ShotCanvas. The project file said build 258. Our release script was about to hand Google Play version code 258, which would have put the new release roughly two thousand builds behind the one already on people’s phones. Meanwhile the App Store had shipped our 1.0 as build 16. Three numbers for what we thought of as one app. Here is where each one came from, what each store actually checks, and the scheme we settled on.

One field, two meanings

ShotCanvas is a Flutter app, so its version lives in one line of pubspec.yaml: 1.2.0+258. The part before the plus is the marketing version people see in the store. The part after it is the build number, and Flutter writes it into both platforms: Android’s versionCode and iOS’s CFBundleVersion. One integer, two stores, and the stores treat it very differently.

Android’s rule is the strict one. Google’s versioning docs describe versionCode as a positive integer where higher means newer, ask that “each successive release of your app uses a greater value,” and say you can’t upload a version code you’ve already used. The phone enforces it too: Android refuses to install an APK with a lower versionCode than the one already installed. That’s downgrade protection, and it doesn’t care what the marketing version says. Play also caps the value at 2,100,000,000.

Apple’s rule is looser than most people assume. Its Xcode Cloud documentation says that iOS, iPadOS, tvOS, visionOS and watchOS apps “can use a lower build number for a new version compared to a previous version,” because App Store Connect requires each version to use a unique combination of marketing version and build number. Mac apps are the exception: their build number must keep increasing even across versions.

Where the 2,000 came from

ShotCanvas 1.1.0 went to Google Play as version code 2239, while the pubspec said 239. We can reconstruct the 2,000 but not prove it, and the reconstruction is the useful part. Our tester builds have always been arm64 APKs built with Flutter’s --split-per-abi flag, and that flag rewrites the version code. In the Flutter Gradle plugin we build with, each ABI gets a code (1 for 32-bit ARM, 2 for arm64, 4 for x86_64) and every split APK ships as abi × 1000 + build. Build 239 on arm64 comes out as 2239. Once a 2000-series code exists on a Play track, every later upload has to clear it.

That formula has a trap past build 999. A 32-bit ARM APK at build 1000 gets 1 × 1000 + 1000 = 2000, the same code as an arm64 APK at build 0, and from there the ranges overlap. Google’s multiple-APK guidance suggests at least seven digits for this reason: configuration in the high digits, version in the low ones, with room between them. If you ship split APKs anywhere, check which codes they actually produced before you assume your build number is your version code.

For 1.2.0 the release script built an app bundle, which doesn’t get the ABI rewrite, so it computed plain 258. Nothing on a phone running 2239 would ever update to it. The fix was one committed file, android/versioncode-offset, containing 2000. The release script reads it and passes --build-number=2258 to the bundle build. The offset now lives in the repository instead of in somebody’s memory of how the last release went.

The iOS number we didn’t choose

The iOS side went the other way. Our builds now come from Xcode Cloud, and Apple’s docs say Xcode Cloud assigns its own build number to every build: an integer that starts at 1 and goes up by one per run. That’s the number App Store Connect shows, whatever your project file says. Our 1.0 went live as build 16 for that reason.

Before we knew the rule, we fought it. App Store Connect already had builds 228 to 230 from earlier local uploads, and we assumed a new build had to beat them. So we wrote a ci_pre_xcodebuild.sh script that set CFBundleVersion to Xcode Cloud’s counter plus 300. Its log line said it worked. The uploaded builds said otherwise: Xcode Cloud applies its own number after that script runs, so the edit never reached the binary. Build 4 went up anyway, below 228, and App Store Connect processed it as valid. The rule we had been defending against didn’t exist for an iOS app. The script is still in the repo as a do-nothing marker, with a note saying why.

If you do need a higher starting point, for example an existing Mac app moving to Xcode Cloud, Apple documents the real lever: in App Store Connect, open the app’s Xcode Cloud tab, then Settings, then Build Number, and edit Next Build Number. Only the Admin and App Manager roles can change it. A script won’t do it for you.

One caution about leaning on Apple’s leniency. Developers on Apple’s forums do report the error “The bundle version must be higher than the previously uploaded version,” so going backwards within a single marketing version is not something to plan around. The safe reading: a new marketing version can start low, and builds within a version should only go up.

What each store checks: Google Play wants every release’s version code to be greater than the last, never reused, and no more than 2,100,000,000. App Store Connect, for iOS, wants each marketing version and build pair to be unique. Mac apps must increase the build number across versions as well.

The scheme we use now

None of this is complicated once it’s written down. The cost came entirely from treating three counters as one. So, per store:

Pick one formula per store and commit it. Ours: Play version code = pubspec build + 2000, read from a file the release script can’t miss. App Store build = whatever Xcode Cloud’s counter says. Neither formula has to be clever; both have to be in the repository rather than in a head or a wiki page.

Never let a code go down on Android. The pubspec number can be reset or renumbered. The Play version code can’t, because a phone will not install a lower one over a higher one. If you change schemes, the new formula has to start above the highest code you ever uploaded to any track, internal testing included.

Don’t build version codes out of dates without doing the arithmetic. A timestamp like yyMMddHHmm looks tidy. Today that’s 2610131200, which is past Play’s 2,100,000,000 ceiling, and any ten-digit code of that shape has been since 2021. yyMMddHH fits. Check the ceiling once rather than finding it on release day.

Read the number off the store, not the project. After an upload, the number that matters is the one App Store Connect or the Play Console shows. When it differs from your project file, believe the store. Then work out which tool rewrote it before you write a script to fight it.

Keep the marketing version honest. On both stores the marketing version is what people read, and on iOS it’s also how a build finds its release: a build only attaches to the App Store version whose number it carries. Bump it before the build that will ship, not after.

The wider point

Build numbers are the dullest part of shipping, which is why they cause trouble. Nobody reviews them and nobody tests them, so they only come up when a release won’t go through. One committed file and one paragraph in the project’s README would have saved us both detours: a script that never ran where we thought it did, and a perfectly good release that came out 2,000 builds short.

The same applies to the rest of a release. Once the build goes up, ShotCanvas handles the listing side, from designing the screenshot set to publishing it to App Store Connect and Google Play, so you’re not doing that by hand on release day too.

Prepare your release screenshots → The build Apple rejected anyway