ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-06-24Advanced

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.

antigravity388android24google-play3ci-cd11versioning2

Premium Article

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 schemeState it readsVisible to other paths?
Bump versionCode +1 by hand in the editorLocal build.gradleNo (local only)
CI adds +1 to the previous build's valueCI cache / last artifactNo (inside CI)
Agent / AI Studio allocates from current time, etc.The runtime environmentNo (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.ByteArrayOutputStream
 
fun 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.

or
Unlock all articles with Membership →
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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

App Dev2026-04-25
Making Sense of Play Console Data with Antigravity — A Practical Guide for Indie Developers
Stop just staring at Play Console dashboards. Learn how to feed your review data, crash reports, and revenue metrics into Antigravity to get concrete improvement actions — a practical guide for solo Android developers.
App Dev2026-06-21
The Back Button Showed an Interstitial Sometimes, Not Others — Rewriting Nested ifs Into a List of Independent Guards
Interstitial display on back press was unstable because nested if statements hid the priority between conditions. Here is how I split it into reason-returning guards and generated tests from a decision table.
App Dev2026-06-17
Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads
A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →