0003: Derive the version code from the version name
Status: accepted
Context
Android and Google Play order builds by versionCode, an integer, not by the version name. Play
rejects an upload whose versionCode it has already seen for the app, and will not offer a build
with a lower code than the installed one as an update; a device refuses to install such an
update too. Until now the code was a hand-set VERSION_CODE=1 in gradle.properties that nothing
bumped: releases 0.1.0 and 0.2.0 both shipped as code 1, and the second Play upload would have
been refused. The guard below requires each release's code to be strictly higher than the
previous release's, which satisfies all of these.
release-please already owns the version name (VERSION_NAME in gradle.properties, rewritten from
Conventional Commit titles). A second number kept in step by hand is a number that drifts.
Decision
The version code is computed from VERSION_NAME at build time; there is no VERSION_CODE
property.
versionCode = major * 10000 + minor * 100 + patch
| Version | Code |
|---|---|
| 0.2.0 | 200 |
| 0.3.0 | 300 (the first release under this scheme; 0.1.0 and 0.2.0 were code 1) |
| 0.99.99 | 9999 |
| 1.0.0 | 10000 |
- The conversion lives in
buildSrc/(VersionCode.fromVersionName) and is unit-tested there;app/build.gradle.ktssetsdefaultConfig.versionCodefrom it, so debug and release builds of one version share one code. - It accepts only
MAJOR.MINOR.PATCH, three dot-separated numbers without leading zeros (0.03.0is refused): novprefix, no pre-release or build suffix. Minor and patch are capped at 99, and the code may not exceed 2100000000, Google Play's largest allowedversionCode. Anything else fails the build with a message naming the value. - Invariant — the conversion never wraps, clamps or truncates. A version it cannot represent fails the build; it never produces some other, smaller code. Under the caps the scheme is strictly increasing: a higher version always has a higher code, including across a carry (0.2.99 → 299 < 0.3.0 → 300; 0.99.99 → 9999 < 1.0.0 → 10000).
- Escape hatch. If a minor or patch ever needs to pass 99, switch to
major * 1000000 + minor * 1000 + patch(caps 999). That switch is always safe: for every version the new code is at least the old one (with minor and patch at most 99, the difference ismajor * 990000 + minor * 900), so the first build under it is higher than every earlier release. Switching the other way is never safe.
Release guard
.github/scripts/check_version_code.py refuses a release whose code is not strictly higher than
the previous release's.
- The previous release is the newest GitHub release by publish time that is neither a draft nor a pre-release, leaving out the tag being released. It is the newest by publish time, not the one with the highest code.
- It downloads that release's
simple-stretch-*-release.apkwithgh release download <tag> --pattern 'simple-stretch-*-release.apk'and reads both codes withaapt2 dump badging. Invariant — the new code is read from the APK that was actually built, never recomputed from the formula, so a build that ignores the formula is still caught. - It passes only when the new code is strictly greater; otherwise it fails, naming both codes and both versions.
- Edge cases:
- no published release at all: passes with a notice (nothing to compare against);
- the previous release has no matching APK, or more than one: fails, naming the tag, and
points to the
backfill_taginput of therelease-pleaseworkflow, which re-attaches it; - the listing, download or
aapt2fails, oraapt2prints output the parser does not recognise: fails (it never passes on a reading it is unsure of); - a manual backfill (
workflow_dispatchwithbackfill_tag) of the newest published release is guarded exactly like a release (--backfill-tagdecides this with the same release selection, then compares against the release before it), so re-attaching cannot bypass a guard that failed on release. Only a backfill of an older release skips the guard, with a notice: it is lower than newer releases by design. - On release: the
build-and-attachjob ofrelease-please.ymlruns the guard on the signed release APK after the signature gate and before the attach step, from themaincheckout like the signature gate. Invariant — a release, or a backfill of the newest release, whose APK's code is not higher than the previous release's gets no APK or AAB attached. - The guard reads only the APK. The AAB is covered only because the APK and the AAB come out
of one Gradle invocation (
assembleRelease bundleRelease) with oneversionCode. Invariant — the release APK and AAB are built in the same Gradle invocation; splitting the AAB build into a separate step or job leaves it unchecked by this guard, and would need the guard run on the AAB as well. - On the release PR: the
release-pr-guardjob runs on every push tomainafter release-please has updated its PR. When a release PR is open it checks out the PR's head, builds the debug APK (same derived code), runs the guard and posts a commit status namedrelease-version-codeon the PR's head commit:success, orfailurewith the reason. A release PR that would publish a lower or equal code is visible before it is merged.
Consequences
- Google Play's own record is the authority, and the guard cannot see it. It compares against the previous GitHub release's APK only. A build uploaded to Play by hand, outside a GitHub release, is invisible to it, and a later release could pass the guard yet still be refused by Play. Checking Play itself needs a Play Developer API credential, which is part of automated upload (#26). Until then, every Play upload should be a GitHub release asset.
- 0.3.0 (code 300) is the first code above the 1 that v0.1.0 and v0.2.0 shipped with. The jump from 1 to 300 is intentional and permanent: codes never go back down.
- Minor and patch stop at 99 until the escape hatch is taken; the build says so rather than publishing a wrong code.
- Pre-release versions (
0.3.0-beta) cannot be built; release-please is configured for plain releases only. - The formula tests are not part of
./gradlew test: buildSrc is a separate build whose tests Gradle does not run for the main build. They run with./gradlew -p buildSrc test, whichpr.ymlruns on every pull request.