fix(release): derive versionCode from build time, versionName from commit time
android / Build + lint + test (push) Successful in 4m19s

versionCode was `git rev-list --count HEAD`, and build.gradle.kts called it
"monotonic forever". It is not, and that claim was sitting directly above
the bug it denied.

A commit count runs ahead on `dev`. So a dev build carried a HIGHER code
than the `main` release meant to supersede it, and Android refuses that
install as a downgrade — a channel you can enter and cannot leave without
uninstalling and losing local data.

Two clocks now, and the split is deliberate even though it reads like an
inconsistency:

The NAME answers "is this the same code?", so it derives from COMMIT time
and reads identically on every lane building this source. A dev build and
a main build of one commit must report the same string. Build time cannot
do that — it prints two numbers for one thing.

The ORDERING KEY answers "may this be installed over that?", so it must be
monotonic BY CONSTRUCTION: minutes since 2020-01-01. Commit time fails
here for the mirror-image reason — rebuild an older commit and it goes
DOWN, which on a phone is a refused install rather than a confusing label.

The non-tag :latest path reconstructed the bundled APK's name with the old
formula, so it is moved to the same commit-timestamp derivation. That
duplication is temporary: once the tag becomes `v<version-name>` it
collapses to `${TAG#v}` with nothing left to keep in step.

Verified locally by running the derivations rather than reasoning about
them: HEAD yields 2026.09.09.1828; the key yields 3519456 against ~1895
from the old scheme, inside int32 with ~4000 years of headroom; a commit
at 00:42 UTC yields "0042", not "42". The workflow now asserts the emitted
shape too — a malformed name builds, signs and publishes happily and only
surfaces as an update nobody is offered, which nobody reports.

That local check is the only verification this commit gets. release.yml
triggers on main and tags only, so nothing on `dev` executes the new
derivation; CI here proves the Gradle file still parses and nothing else.

Also confirms the migration constraint recorded in milestone #390: this
commit would name a release 2026.09.09.1828, which is LOWER than the
installed 2026.09.09.1895 under name comparison. The first new-scheme
release must be cut on a later calendar day, or existing installs will
never be offered it.

Step 1 of 5 — Scribe task #3808, milestone #390.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-09 21:37:30 -04:00
co-authored by Claude Opus 5
parent c27f9d484a
commit bfdaed9365
2 changed files with 86 additions and 27 deletions
+68 -20
View File
@@ -24,9 +24,10 @@ name: release
# :latest (not just tags), a main build with no APK would silently strip # :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on # the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK # non-tag builds image-release pulls the MOST RECENT release's signed APK
# and reconstructs its exact versionName (tag + commit-count, the same # and reconstructs its exact versionName from the tagged commit's timestamp
# formula android-release bakes in) for the version sidecar — no rebuild, # (the same derivation android-release bakes in) for the version sidecar —
# just rebundle. Tag builds keep bundling their own freshly-built APK. # no rebuild, just rebundle. Tag builds keep bundling their own
# freshly-built APK.
# #
# Android testing (lint + detekt + unit tests, debug APK upload on main) # Android testing (lint + detekt + unit tests, debug APK upload on main)
# lives in android.yml and runs independently on every push. # lives in android.yml and runs independently on every push.
@@ -80,9 +81,12 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# fetch-depth: 0 retrieves full history; default shallow clone # Full history. The version name now reads only the tip commit's
# would return 1 for `git rev-list --count HEAD`, breaking the # timestamp, so a shallow clone would technically serve — but this
# iteration suffix. # job derives a value that ships to devices, and a shallow checkout
# changes what git-derived values resolve to WITHOUT failing. The
# whole failure class here is a green build carrying a wrong
# version, so the cheap guarantee is worth keeping.
fetch-depth: 0 fetch-depth: 0
- name: Compute release version - name: Compute release version
@@ -91,12 +95,45 @@ jobs:
working-directory: ${{ github.workspace }} working-directory: ${{ github.workspace }}
run: | run: |
set -euo pipefail set -euo pipefail
TAG="${GITHUB_REF#refs/tags/v}"
COMMIT_COUNT=$(git rev-list --count HEAD) # Two different clocks, deliberately. They answer different
VERSION_NAME="${TAG}.${COMMIT_COUNT}" # questions, and using one for both breaks whichever it fits worse.
#
# The NAME answers "is this the same code?" — so it derives from
# COMMIT time and reads identically on every lane that builds this
# source. A dev build and a main build of one commit must report the
# same string; build time cannot do that, it prints two numbers for
# one thing.
COMMIT_TS=$(git log --format=%ct -1 HEAD)
VERSION_NAME=$(date -u -d "@${COMMIT_TS}" +%Y.%m.%d.%H%M)
# The ORDERING KEY answers "may this be installed over that?" — so it
# must be monotonic BY CONSTRUCTION. Minutes since 2020-01-01: ~3.5M
# today, ~525k/year, against a 2^31 ceiling.
#
# This replaced `git rev-list --count HEAD`, which was NOT monotonic
# and was commented as if it were. A commit count runs ahead on `dev`,
# so a dev build outranked the `main` release that superseded it and
# Android refused the install as a downgrade — a channel you could
# enter and not leave without uninstalling.
#
# Commit time would be wrong here too, for the mirror-image reason:
# rebuild an older commit and it goes DOWN, which on a phone is a
# refused install rather than a merely confusing label.
VERSION_CODE=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
# Assert the emitted shape at the source. A malformed name still
# builds, signs and publishes perfectly happily, and only surfaces as
# an update nobody is ever offered — which nobody reports, because
# "no update available" and "I cannot read this" look identical.
if [[ ! "${VERSION_NAME}" =~ ^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]{4}$ ]]; then
echo "::error::version name '${VERSION_NAME}' is not YYYY.MM.DD.HHMM"
exit 1
fi
echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT" echo "name=${VERSION_NAME}" >> "$GITHUB_OUTPUT"
echo "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT" echo "code=${VERSION_CODE}" >> "$GITHUB_OUTPUT"
echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})" echo "::notice::APK version: ${VERSION_NAME} (code=${VERSION_CODE})"
# Checked BEFORE the expensive work, not after it. "Attach APK to gitea # Checked BEFORE the expensive work, not after it. "Attach APK to gitea
# Release" below resolves the release by tag and fails if it is absent — # Release" below resolves the release by tag and fails if it is absent —
@@ -223,8 +260,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
# Full history + tags so non-tag :latest builds can resolve the # Full history + tags so non-tag :latest builds can resolve the
# latest release tag's commit count and reconstruct the bundled # latest release tag's commit and reconstruct the bundled APK's
# APK's exact versionName (see "Bundle latest release APK" below). # exact versionName from its timestamp (see "Bundle latest release
# APK" below).
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@@ -309,9 +347,10 @@ jobs:
# Main pushes don't build an APK, but they DO move :latest — so # Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest # without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and # until the next tag. Pull the most-recent release's signed APK and
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count # reconstruct its exact versionName from the tagged commit's
# TAG) — identical to android-release's formula) so the version # timestamp — the same derivation android-release uses — so the
# sidecar the server hands clients matches the installed build. # version sidecar the server hands clients matches the installed
# build.
# Degrades to an empty client/ (404 update channel) — never a wrong # Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release / APK asset / tag-count can be resolved. # version — if no release / APK asset / tag-count can be resolved.
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v') if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
@@ -331,11 +370,20 @@ jobs:
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi fi
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)" # Reconstruct the bundled APK's name with the SAME derivation
if [ -z "${COUNT}" ]; then # android-release uses — commit timestamp of the tagged commit. The
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0 # two must agree exactly: this string is what the server hands
# clients to compare against what is installed, so a mismatch here
# is an update offered forever or never offered at all.
#
# This duplication is temporary. Once the tag itself becomes
# `v<version-name>`, this whole block collapses to `${TAG#v}` with
# nothing to recompute and nothing to keep in step.
COMMIT_TS="$(git log --format=%ct -1 "${TAG}" 2>/dev/null || true)"
if [ -z "${COMMIT_TS}" ]; then
echo "::notice::could not resolve commit timestamp for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
fi fi
VERSION_NAME="${TAG#v}.${COUNT}" VERSION_NAME="$(date -u -d "@${COMMIT_TS}" +%Y.%m.%d.%H%M)"
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}" curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
echo "${VERSION_NAME}" > client/minstrel.apk.version echo "${VERSION_NAME}" > client/minstrel.apk.version
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}" echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
+18 -7
View File
@@ -21,13 +21,24 @@ android {
applicationId = "com.fabledsword.minstrel" applicationId = "com.fabledsword.minstrel"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
// versionName / versionCode are released-build values injected by // versionName / versionCode are released-build values injected by CI.
// CI from the git tag + commit count. Local / debug builds fall // Local / debug builds fall back to "dev" so the About card reads
// back to "dev" so the About card reads honestly. Releases ship // honestly.
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and //
// versionCode=<commits>, which is monotonic forever and lets the // versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so
// shared isVersionNewer comparator distinguish two same-day // every lane building this source reports the same string and the
// re-cuts (the iteration suffix differs). // channel is the only thing that differs between them.
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction.
//
// This comment used to say versionCode was a commit count and that it
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
val versionNameOverride = val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride = val versionCodeOverride =