Versioning rework, a dev channel, and the miniplayer gap #129

Merged
bvandeusen merged 8 commits from dev into main 2026-09-10 08:33:26 -04:00
2 changed files with 86 additions and 27 deletions
Showing only changes of commit bfdaed9365 - Show all commits
+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 =