Pin OTA releases to main and stop dev builds outranking them

Two ways the updater could have served or blocked the wrong build:

- release.yml also triggers on workflow_dispatch, which can be aimed at
  any branch from the Actions UI. That would have built dev code and
  published it as a real Release, which every install would then offer to
  download. The job now refuses to run off anything but main.

- versionCode came from GITHUB_RUN_NUMBER, but that counter is per
  workflow, so the dev pipeline had its own independent sequence. A debug
  APK installed from a CI artifact already carried a higher versionCode
  than the first several releases would, and the updater's check is
  latest <= installed, so those users would have been told they were up
  to date indefinitely. Only release.yml now sets PLG_RELEASE, and only
  that stamps a real version; everything else stays at 1.0.0-dev.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-05 23:47:20 +00:00
parent 85ec10836b
commit 80ff178437
3 changed files with 39 additions and 7 deletions
+9
View File
@@ -19,7 +19,16 @@ concurrency:
jobs: jobs:
build: build:
name: Build APK and publish release name: Build APK and publish release
# workflow_dispatch can be aimed at any branch from the Actions UI. Releases
# are what the in-app updater installs, so only main may produce one —
# anything else is a no-op rather than shipping unreviewed code to everyone.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Tells the build this is a real release, so the APK is stamped with the
# run number that matches the tag below. Absent everywhere else, which
# keeps dev and local builds at 1.0.0-dev.
PLG_RELEASE: "1"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
+10 -1
View File
@@ -72,11 +72,20 @@ Requires JDK 17 and the Android SDK (compileSdk 34).
## CI ## CI
- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull - `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull
requests into `dev` or `main`. requests into `dev` or `main`. Its APK artifact is a dev build, versioned
`1.0.0-dev`.
- `.github/workflows/release.yml` — on push to `main`, builds the APK and - `.github/workflows/release.yml` — on push to `main`, builds the APK and
publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater publishes it as a GitHub Release tagged `v1.0.<run-number>`. The in-app updater
reads that tag, which is why the APK's `versionCode` is the same run number. reads that tag, which is why the APK's `versionCode` is the same run number.
**OTA updates follow `main`, and only `main`.** The updater asks GitHub for the
latest published Release; the release workflow is the only thing that creates
one, and it refuses to run off any branch but `main` — so a manual dispatch
aimed at `dev` does nothing rather than shipping unreviewed code to every
install. Only that workflow sets `PLG_RELEASE`, which is what stamps a real
`versionCode`; dev builds stay at `1.0.0-dev` so a side-loaded CI artifact can
never outrank a genuine release and suppress future updates.
The debug keystore is committed on purpose: every build is signed with the same The debug keystore is committed on purpose: every build is signed with the same
key, so an OTA update installs over the previous one instead of being rejected key, so an OTA update installs over the previous one instead of being rejected
for a signature mismatch. for a signature mismatch.
+20 -6
View File
@@ -4,10 +4,20 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
} }
// On CI, GITHUB_RUN_NUMBER is set automatically and matches the release tag // Only the release workflow stamps a real version. It sets PLG_RELEASE, and its
// (v1.0.<run-number>), so the shipped APK knows its own version and the // GITHUB_RUN_NUMBER is the same number as the release tag it publishes
// in-app updater can tell whether a newer release exists. Locally it's a dev build. // (v1.0.<run-number>), so the shipped APK knows its own version and the in-app
val ciRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 // updater can compare against the latest published release.
//
// Everything else — a local build, or the dev CI pipeline — stays at 1.0.0-dev.
// GITHUB_RUN_NUMBER alone is NOT enough to key off: it counts per workflow, so
// the dev pipeline has its own independent sequence. A debug APK installed from
// a CI artifact would otherwise carry that counter as its versionCode, could
// outrank a genuine release, and would leave the updater reporting "you're on
// the latest version" indefinitely.
val isReleaseBuild = System.getenv("PLG_RELEASE") == "1"
val releaseRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0
val stampReleaseVersion = isReleaseBuild && releaseRunNumber > 0
android { android {
namespace = "org.prolibertate.games" namespace = "org.prolibertate.games"
@@ -17,8 +27,12 @@ android {
applicationId = "org.prolibertate.games" applicationId = "org.prolibertate.games"
minSdk = 24 minSdk = 24
targetSdk = 34 targetSdk = 34
versionCode = if (ciRunNumber > 0) ciRunNumber else 1 // Dev builds sit at 1, below every release except the very first —
versionName = if (ciRunNumber > 0) "1.0.$ciRunNumber" else "1.0.0-dev" // a v1.0.1 release ties with a dev build and so is not offered as an
// update. Harmless, and preferable to versionCode 0, which Android
// documents as out of range.
versionCode = if (stampReleaseVersion) releaseRunNumber else 1
versionName = if (stampReleaseVersion) "1.0.$releaseRunNumber" else "1.0.0-dev"
} }
signingConfigs { signingConfigs {