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 =