From bfdaed936556269ea77c1a3016097ab4b648c565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 9 Sep 2026 21:37:30 -0400 Subject: [PATCH] fix(release): derive versionCode from build time, versionName from commit time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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) Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH --- .gitea/workflows/release.yml | 88 ++++++++++++++++++++++++++++-------- android/app/build.gradle.kts | 25 +++++++--- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6e89d541..050f3fa3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -24,9 +24,10 @@ name: release # :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 # non-tag builds image-release pulls the MOST RECENT release's signed APK -# and reconstructs its exact versionName (tag + commit-count, the same -# formula android-release bakes in) for the version sidecar — no rebuild, -# just rebundle. Tag builds keep bundling their own freshly-built APK. +# and reconstructs its exact versionName from the tagged commit's timestamp +# (the same derivation android-release bakes in) for the version sidecar — +# no rebuild, just rebundle. Tag builds keep bundling their own +# freshly-built APK. # # Android testing (lint + detekt + unit tests, debug APK upload on main) # lives in android.yml and runs independently on every push. @@ -80,9 +81,12 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - # fetch-depth: 0 retrieves full history; default shallow clone - # would return 1 for `git rev-list --count HEAD`, breaking the - # iteration suffix. + # Full history. The version name now reads only the tip commit's + # timestamp, so a shallow clone would technically serve — but this + # 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 - name: Compute release version @@ -91,12 +95,45 @@ jobs: working-directory: ${{ github.workspace }} run: | set -euo pipefail - TAG="${GITHUB_REF#refs/tags/v}" - COMMIT_COUNT=$(git rev-list --count HEAD) - VERSION_NAME="${TAG}.${COMMIT_COUNT}" + + # Two different clocks, deliberately. They answer different + # 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 "code=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT" - echo "::notice::APK version: ${VERSION_NAME} (code=${COMMIT_COUNT})" + echo "code=${VERSION_CODE}" >> "$GITHUB_OUTPUT" + echo "::notice::APK version: ${VERSION_NAME} (code=${VERSION_CODE})" # 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 — @@ -223,8 +260,9 @@ jobs: uses: actions/checkout@v4 with: # Full history + tags so non-tag :latest builds can resolve the - # latest release tag's commit count and reconstruct the bundled - # APK's exact versionName (see "Bundle latest release APK" below). + # latest release tag's commit and reconstruct the bundled APK's + # exact versionName from its timestamp (see "Bundle latest release + # APK" below). fetch-depth: 0 fetch-tags: true @@ -309,9 +347,10 @@ jobs: # Main pushes don't build an APK, but they DO move :latest — so # without this the in-app update channel would vanish from :latest # until the next tag. Pull the most-recent release's signed APK and - # reconstruct its exact versionName (${TAG#v}.$(git rev-list --count - # TAG) — identical to android-release's formula) so the version - # sidecar the server hands clients matches the installed build. + # reconstruct its exact versionName from the tagged commit's + # timestamp — the same derivation android-release uses — so the + # version sidecar the server hands clients matches the installed + # build. # Degrades to an empty client/ (404 update channel) — never a wrong # version — if no release / APK asset / tag-count can be resolved. if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v') @@ -331,11 +370,20 @@ jobs: if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0 fi - COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)" - if [ -z "${COUNT}" ]; then - echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0 + # Reconstruct the bundled APK's name with the SAME derivation + # android-release uses — commit timestamp of the tagged commit. The + # 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`, 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 - 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}" echo "${VERSION_NAME}" > client/minstrel.apk.version echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4ab52a4b..dc6d264b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,13 +21,24 @@ android { applicationId = "com.fabledsword.minstrel" minSdk = 26 targetSdk = 36 - // versionName / versionCode are released-build values injected by - // CI from the git tag + commit count. Local / debug builds fall - // back to "dev" so the About card reads honestly. Releases ship - // versionName="YYYY.MM.DD." (e.g. "2026.06.02.142") and - // versionCode=, which is monotonic forever and lets the - // shared isVersionNewer comparator distinguish two same-day - // re-cuts (the iteration suffix differs). + // versionName / versionCode are released-build values injected by CI. + // Local / debug builds fall back to "dev" so the About card reads + // honestly. + // + // versionName is "YYYY.MM.DD.HHMM" from the COMMIT's timestamp, so + // every lane building this source reports the same string and the + // 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 = (project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() } val versionCodeOverride =