Three Paths Were Each Picking Their Own Number — Deriving versionCode From a Single Source So Releases Stop Stalling
Now that AI Studio can generate an app from one prompt and push it straight to Play's internal testing track, three paths — you, CI, and the agent — each allocate versionCode on their own and collide. Here's how to derive the number from a single source and add a pre-upload guard, with working code.
One morning the overnight release pipeline had stopped on "Version code 1037 has already been used. Try another version code." When I traced it, there wasn't a single culprit. The night before I had pushed one build from my machine to internal testing, CI had built an AAB from the same commit and assigned the same number, and AI Studio had streamed a generated verification build to the internal track as well. Three paths, each allocating versionCode in its own way.
Since I/O 2026, AI Studio can generate a Kotlin/Jetpack Compose app from a text prompt, run it in an embedded emulator, push it to a physical device over USB, and deliver it all the way to Google Play's internal testing track from one screen. I'm glad the distance from "build it" to "ship it" collapsed, but behind that convenience, one more path to Play has appeared. On top of manual and CI uploads, a path where "the machine ships on its own" is now a permanent resident.
To Play, versionCode is the integer that orders an app's builds uniquely. It has to increase monotonically across tracks, and a number used once can never be reused. If a single actor allocates the number, collisions don't happen. The problem is that the number of allocators grew to three, and none of them knows how high the others have gone.
Why three paths collide
The heart of the collision is that "who decides the next number" is distributed. The common allocation schemes each look at different state.
Allocation scheme
State it reads
Visible to other paths?
Bump versionCode +1 by hand in the editor
Local build.gradle
No (local only)
CI adds +1 to the previous build's value
CI cache / last artifact
No (inside CI)
Agent / AI Studio allocates from current time, etc.
The runtime environment
No (that run only)
Each scheme works fine on its own. But when all three push to the same app in parallel, they don't share "how high have we gone," so they assign the same number twice or assign a lower number afterward. Play demands monotonic increase and uniqueness, so a later low number or a duplicate gets rejected and the release stalls.
Having shipped apps solo for a long time, my sense is that this class of failure can't be fixed with "smarter." Making each path bump +1 more cleverly leaves the collision in place if the state they read is fragmented. What works is consolidating how the number is decided.
Deriving versionCode from a single source
In this situation I choose to take versionCode out of "a value a human holds by hand" and turn it into a deterministic function that yields the same integer no matter who computes it. The most workable single source is the repository history itself. The number of commits on main is the same value computed from any machine, by any path.
// app/build.gradle.kts// Derive versionCode deterministically from the commit count of main.// The point: the same integer whether computed locally, in CI, or by an agent.import java.io.ByteArrayOutputStreamfun gitVersionCode(): Int { // A shallow clone in CI loses commits, so leave an escape hatch // that lets an environment variable override the value. System.getenv("VERSION_CODE_OVERRIDE")?.toIntOrNull()?.let { return it } val out = ByteArrayOutputStream() val result = exec { commandLine("git", "rev-list", "--count", "HEAD") standardOutput = out isIgnoreExitValue = true } val count = out.toString().trim().toIntOrNull() require(result.exitValue == 0 && count != null && count > 0) { "Could not derive versionCode from commit count. " + "For a shallow clone, set fetch-depth: 0 or pass VERSION_CODE_OVERRIDE." } // Add a base offset so the new numbers never collide with the // hand-allocated band you used in the past. return 100000 + count}android { defaultConfig { versionCode = gitVersionCode() versionName = "2.${gitVersionCode() - 100000}" }}
Two things matter here. One is that a shallow clone in CI reports a commit count that diverges from reality, so set fetch-depth: 0 (fetch full history) or allow an explicit override. The other is to shift the new numbering band away from the band you used under hand allocation (say the 1000s) with a base offset. When I migrated, I added an offset comfortably larger than the maximum of the old band so old and new never cross.
You can also use a CI build number as the source of truth. On GitHub Actions, github.run_number increases monotonically, so passing it into VERSION_CODE_OVERRIDE consolidates allocation around CI. Whether you make repository history or the CI build number your truth, the essential thing is to pick exactly one and align every path to it. If there are two sources of truth, there is no truth.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If your automated releases have been stalling on 'Version code already used', you can build a setup today where no path — manual, CI, or agent — ever collides on the number
✦You'll be able to derive versionCode deterministically from git commit count or a CI build number, and add a guard that checks Play's current highest value before uploading
✦You'll avoid undocumented traps like the shared numbering space across internal/closed/production and the monotonic-increase constraint, so staged rollout keeps flowing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Checking against Play's current value before uploading
Even after consolidating on deterministic allocation, during the transition old-scheme builds may linger on Play, or a higher number may already sit on another track. So just before upload, add a guard that queries the cross-track highest versionCode via the Play Developer Publishing API and checks that the number you're about to push exceeds it.
#!/usr/bin/env python3"""Pre-upload guard: verify the number you're about to push is greater thanthe maximum versionCode across all tracks on Play. Fail-fast if not."""import sysfrom googleapiclient.discovery import buildfrom google.oauth2 import service_accountPACKAGE = "com.example.wallpaper"SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]def highest_version_code(service, edit_id: str) -> int: highest = 0 tracks = service.edits().tracks().list( packageName=PACKAGE, editId=edit_id ).execute().get("tracks", []) for track in tracks: for release in track.get("releases", []): # Note: versionCodes comes back as a list of strings for vc in release.get("versionCodes", []) or []: highest = max(highest, int(vc)) return highestdef main(next_code: int) -> int: creds = service_account.Credentials.from_service_account_file( "play-service-account.json", scopes=SCOPES) service = build("androidpublisher", "v3", credentials=creds) edit = service.edits().insert(packageName=PACKAGE, body={}).execute() edit_id = edit["id"] try: current = highest_version_code(service, edit_id) print(f"Highest versionCode on Play = {current} / about to push = {next_code}") if next_code <= current: print("BLOCK: monotonic-increase violation. Aborting upload.", file=sys.stderr) return 1 print("OK: no collision.") return 0 finally: # This is a read-only check, so discard the edit without committing service.edits().delete(packageName=PACKAGE, editId=edit_id).execute()if __name__ == "__main__": sys.exit(main(int(sys.argv[1])))
Putting this guard as the first step of every path turns "find out after Play rejects it" into "stop on your own machine before pushing." The point is to run this same one script first from the manual script, from the CI job, and from the agent's run alike. Always discard the edit you created for the check without committing it — leaving it dangles a half-finished editing session on the app.
Falling back to idempotent on collision
When the guard stops you, being able to recompute the number on the spot and retry lets overnight automation keep running without a hand-fix. As long as you've consolidated on deterministic allocation, the retry is safe. The same commit yields the same number; a moved-forward commit yields the next number up, so you never accidentally ship twice.
#!/usr/bin/env bash# allocate -> check -> (on collision, recompute and retry once) -> uploadset -euo pipefailcompute_version_code() { git rev-list --count HEAD | awk '{print 100000 + $1}'; }upload_with_guard() { local code; code="$(compute_version_code)" if python3 play_guard.py "$code"; then echo "Uploading: versionCode=$code" # ./gradlew bundleRelease && fastlane supply --track internal ... return 0 fi return 1}if ! upload_with_guard; then echo "Collision detected. Refetching latest and retrying once." git fetch origin main --depth=0 && git checkout main && git pull --ff-only upload_with_guard || { echo "Retry failed too. Handing off to a human."; exit 1; }fi
The "retry only once" limit is there so we don't hammer uploads in an infinite loop. The safety of re-running rests on the allocation being deterministic. With hand allocation, the number grows on every retry and you lose track of what's authoritative. It's precisely to make re-runs safe that consolidating the number onto a single source of truth earns its keep. I've written more about idempotent re-runs in designing an overlap guard for scheduled runs.
How collisions showed up per scheme
I recorded how often automated uploads to internal testing got rejected before and after the migration. This is 28 days of pushing to the same app from three paths.
Allocation scheme
Upload attempts
Rejected on versionCode collision
Collision rate
Each path bumps +1 independently (old)
214
23
~10.7%
Unified on git commit count as single source
231
2
~0.9%
Above + pre-upload guard
231
0
0%
The two that remained were transitional, caused by old-band numbers still sitting on Play right after the migration. Once I shifted the base offset comfortably above the maximum of the old band, collisions dropped to zero together with the guard. The numbers look modest, but the felt difference of "it stops stalling overnight" is large — I no longer start my mornings cleaning up a failed release.
Traps the official guides don't mention
When you can push all the way to delivery from one screen, it's easy to miss that versionCode carries a few constraints that are rarely spelled out.
internal/closed/production are separate tracks, but they share a single versionCode space. Push a high number to internal testing and that number is no longer usable in production. To avoid burning numbers on verification builds, assign generated trial uploads a separate high band distinct from the base offset, so production allocation stays clean.
The versionCode ceiling is 2100000000. Turning a timestamp (say yyyyMMddHH) straight into an integer grows the digits and creeps toward that limit. I removed this worry by consolidating on a commit-count base.
And if you upload a project generated by AI Studio or an agent as-is, its applicationId may match your production app, so the verification build slips into testers' environments. I settled on giving generated builds an applicationIdSuffix (say .preview), running pre-release verification through the machine with the approach in designing the Android CLI as a verification gate, and having a human hold only the final push.
Where to start
First, take versionCode out of the hand-written value in build.gradle and consolidate on a deterministic derivation: git rev-list --count HEAD plus a base offset. That alone makes manual-vs-CI collisions all but disappear. Then it's enough to drop the pre-upload guard in as a single first step at the head of every path.
Even in an era where the machine ships builds on its own, on the one point of the number, simply "keeping the truth singular" makes releases quiet. I'm still tidying up my own setup, but the peace of mind of not stalling overnight feels like the biggest win. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.