diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b817b60..9a0aaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: # Stamp the APK as a dev-channel build exactly when it is a build of the # dev branch, which is exactly when it can be published below. Pull # request builds run against refs/pull/N/merge, so they stay unstamped at - # 1.0.0-dev: they are never published and must not claim a channel + # .0-dev: they are never published and must not claim a channel # version. # # This condition has to stay in step with the publish steps' own. Stamping @@ -96,7 +96,12 @@ jobs: if: github.ref == 'refs/heads/dev' run: | set -euo pipefail - version="v1.0.${{ github.run_number }}-dev" + # The series comes from gradle.properties, which is the same file the + # build read when it stamped the APK — so the tag and what is inside + # it cannot name different versions. + series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" + test -n "$series" + version="v${series}.${{ github.run_number }}-dev" src="app/build/outputs/apk/debug/app-debug.apk" dest="ProLibertateGames-${version}.apk" cp "$src" "$dest" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 978bf67..8bd1fc9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,12 @@ jobs: id: stage run: | set -euo pipefail - version="v1.0.${{ github.run_number }}" + # The series comes from gradle.properties, which is the same file the + # build read when it stamped the APK — so the tag and what is inside + # it cannot name different versions. + series="$(sed -n 's/^plgVersionSeries=//p' gradle.properties)" + test -n "$series" + version="v${series}.${{ github.run_number }}" src="app/build/outputs/apk/debug/app-debug.apk" dest="ProLibertateGames-${version}.apk" cp "$src" "$dest" diff --git a/CLAUDE.md b/CLAUDE.md index 5365101..98dcdf1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ Skipping step 4 leaves a branch whose head was never built and no release. Both long-lived branches publish, so a merge is a release, not just an integration: -- **`dev`** → `ci.yml` publishes a `v1.0.N-dev` **prerelease**, offered by the +- **`dev`** → `ci.yml` publishes a `v.N-dev` **prerelease**, offered by the in-app updater to anyone on the Dev channel. - **`main`** → `release.yml` publishes a **production** release, which the updater installs for everyone on the Production channel. diff --git a/README.md b/README.md index beb34d7..b71f5f2 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,10 @@ production package instead. - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull requests into `dev` or `main`. Its APK artifact is a dev build, versioned - `1.0.0-dev`. + `.0-dev` — the series being whatever `plgVersionSeries` says in + `gradle.properties`, currently **1.1**. - `.github/workflows/release.yml` — on push to `main`, builds the APK and - publishes it as a GitHub Release tagged `v1.0.`. The in-app updater + publishes it as a GitHub Release tagged `v.`. The in-app updater reads that tag, which is why the APK's `versionCode` is the same run number. ### Installing without the app @@ -186,14 +187,34 @@ prereleases by design, and that is exactly what keeps a production install from being handed a dev build. Dev builds are reached through the in-app updater with the channel set to dev, or from the releases page. +### The version series + +A version is **`.`**. The series is set by hand, once, in +`gradle.properties`: + +```properties +plgVersionSeries=1.1 +``` + +Both workflows read that same property when they name a release tag, and the +build reads it when it stamps the APK, so the tag and what is inside it cannot +name different versions. Bumping a series is a one-line change to that file and +nothing else. + +The number after it is the CI run number and keeps climbing on its own, which is +what makes a series bump safe: `versionCode` is the run number *alone*, so +going from 1.0 to 1.1 never looks like a downgrade to Android and never +interrupts an OTA update. The series is a name for people; the run number is +what the machines compare. + ### Update channels Both pipelines publish, to two separate channels: | Channel | Published by | Tag | Marked | | --- | --- | --- | --- | -| Production | `release.yml`, push to `main` | `v1.0.` | release | -| Dev | `ci.yml`, any build of `dev` | `v1.0.-dev` | prerelease | +| Production | `release.yml`, push to `main` | `v.` | release | +| Dev | `ci.yml`, any build of `dev` | `v.-dev` | prerelease | Dev builds are published as **prereleases** deliberately: GitHub's "latest release" endpoint skips prereleases, so a production-channel install can never diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 94b98c4..4a0628f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,6 +16,11 @@ val buildChannel = System.getenv("PLG_CHANNEL").orEmpty() val runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 val stampVersion = buildChannel.isNotEmpty() && runNumber > 0 +// The version series, from gradle.properties. Both workflows read the same +// property when they name a release tag, so the tag and the APK inside it +// cannot disagree about which series they belong to. +val versionSeries = (project.findProperty("plgVersionSeries") as String?) ?: "1.0" + // Anything that is not an explicit production build is a dev build: ci.yml // stamps "dev", and a local build with nothing set counts as dev too. // @@ -43,12 +48,15 @@ android { // know its own channel, so it must survive into the shipped APK. It // tracks isDevBuild rather than the stamp alone, so an unstamped local // build cannot end up claiming a channel its applicationId contradicts. + // versionCode stays the run number and nothing else, so it keeps + // climbing across a change of series: bumping 1.0 to 1.1 must not make + // an update look like a downgrade to Android. versionCode = if (stampVersion) runNumber else 1 versionName = when { - stampVersion && buildChannel == "production" -> "1.0.$runNumber" - stampVersion -> "1.0.$runNumber-dev" - isDevBuild -> "1.0.0-dev" - else -> "1.0.0" + stampVersion && buildChannel == "production" -> "$versionSeries.$runNumber" + stampVersion -> "$versionSeries.$runNumber-dev" + isDevBuild -> "$versionSeries.0-dev" + else -> "$versionSeries.0" } // The launcher label. With both copies on the phone the app drawer shows diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt index 4d4f2c5..67aa69d 100644 --- a/app/src/main/java/org/prolibertate/games/update/Updater.kt +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -38,7 +38,7 @@ private const val DEV_SUFFIX = "-dev" /** * Over-the-air updates from the repository's GitHub Releases. * - * Release tags are v1.0. and the APK's versionCode is that same run + * Release tags are v. and the APK's versionCode is that same run * number, so comparing the two is enough to tell whether a newer build exists. * The repository's releases must be publicly downloadable — no token is * embedded in the app, and GitHub blocks anonymous access to private assets. @@ -112,7 +112,9 @@ class Updater(private val activity: Activity) { private fun toRelease(json: JSONObject): Release? { val tag = json.optString("tag_name").ifBlank { return null } - // Tags are v1.0. or v1.0.-dev; the run number is the trailing part. + // Tags are v., with -dev on a prerelease. Only the trailing + // run number is read: the series is a name for people and changing it + // must not make an older build look newer. val versionCode = tag.substringAfterLast('.') .removeSuffix(DEV_SUFFIX) .toIntOrNull() diff --git a/gradle.properties b/gradle.properties index 23ff864..b8b27b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,3 +11,15 @@ kotlin.code.style=official # Only build the resources needed for the configuration being built. android.nonTransitiveRClass=true + +# The version series every build and every release tag is named for: an APK is +# ., a production tag is v. and a dev tag adds +# -dev. It lives here rather than in the build file because the workflows have +# to name the tag as well, and they read it straight out of this file — three +# copies of "1.1" would be three chances for a release to be tagged one thing +# and built as another. +# +# Only the series is set by hand. The number after it is the CI run number, so +# it keeps climbing on its own and an APK's versionCode never goes backwards +# when this changes. +plgVersionSeries=1.1