From a53afa12e4ea4b0f7ed6db2d1dea20701f682f9f Mon Sep 17 00:00:00 2001 From: bubulux Date: Fri, 23 Oct 2026 15:24:00 +0200 Subject: [PATCH] Staging version code from the build time, so staging can be rolled back on the phone The commit count tied the code to history: a build of an older tag got a lower code, and Android never installs a lower one over a higher one. Minutes since 2020-01-01 UTC rise with every build whatever ref it comes from, so an older tag installs as an ordinary update and the phone keeps its data. Release codes stay as they are. releasing.md also describes testing a release tag on staging before production. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-phone-app.yml | 10 +++++----- apps/mobile/app.config.ts | 11 ++++++----- docs/releasing.md | 3 ++- docs/self-hosting.md | 2 +- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-phone-app.yml b/.github/workflows/build-phone-app.yml index 7c4a28f..b920b31 100644 --- a/.github/workflows/build-phone-app.yml +++ b/.github/workflows/build-phone-app.yml @@ -188,13 +188,13 @@ jobs: export ANDROID_KEYSTORE="$RUNNER_TEMP/release.jks" task=assembleRelease out=release name="openclapper-$version-$GIT_COMMIT.apk" # The staging app: its own name, package id and icon (app.config.ts). Its version code is the - # commit count, so every build of main is a new version for Play (deploy#10): it needs the full - # history, which a run with `ref` checks out; a shallow checkout would count 1. + # build time, minutes since 2020-01-01 UTC: every build is higher than the one before, whatever + # ref it comes from, so Play installs an older tag as an ordinary update (a rollback keeps the + # phone's data). Builds in the same minute share a code; Play then keeps the first. if [ "$VARIANT" = staging ]; then export APP_VARIANT=staging name="openclapper-staging-$version-$GIT_COMMIT.apk" - export GIT_COMMIT_COUNT=$(git rev-list --count HEAD) - [ "$GIT_COMMIT_COUNT" -gt 1 ] || { echo "::error::A staging build needs the full history for its version code: run it with \`ref\` (a branch or main)"; exit 1; } - echo "Version code: $GIT_COMMIT_COUNT (commit count)" + export STAGING_VERSION_CODE=$(( ($(date -u +%s) - $(date -u -d 2020-01-01 +%s)) / 60 )) + echo "Version code: $STAGING_VERSION_CODE (minutes since 2020-01-01 UTC)" fi # The App Bundle for Play next to the APK for sideloading, from the same build and key. task="$task bundleRelease" diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9bbe162..416ccf8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -12,13 +12,14 @@ function versionCode(v: string): number { } /** - * The staging app is built from main between releases, and Play takes each version code once, so it - * counts commits instead (GIT_COMMIT_COUNT, set by the Build phone app workflow from a full checkout). Without - * the count (a local config run) it falls back to the release scheme. + * The staging app is built from any ref between releases, and Android only installs a higher version + * code, so it uses the build time instead (STAGING_VERSION_CODE, minutes since 2020, set by the Build + * phone app workflow): a build of an older tag still installs as an update. Without it (a local config + * run) it falls back to the release scheme. */ function stagingVersionCode(): number { - const count = Number.parseInt(process.env.GIT_COMMIT_COUNT ?? "", 10); - return count > 1 ? count : versionCode(version); + const code = Number.parseInt(process.env.STAGING_VERSION_CODE ?? "", 10); + return code > 0 ? code : versionCode(version); } /** diff --git a/docs/releasing.md b/docs/releasing.md index 86f0098..95b189e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -16,7 +16,8 @@ OpenClapper has **one version for the whole product**: the Worker and the Studio - Admin board: its header. - Phone: the Setup footer and the debug log. - **Android**: `versionName` is the version and `versionCode` is derived from it (`major·1 000 000 + minor·1 000 + patch`: 0.1.0 → 1000), so it grows with every release (`apps/mobile/app.config.ts`). Builds between releases carry the same code as the last release; Android installs a same-code update over it. Google Play takes each code once, so production goes to Play once per release. -- **The staging app** (`APP_VARIANT=staging`) counts commits instead (`git rev-list --count HEAD`, Build phone app sets it from a full checkout), so every build of `main` is a new version for Play. A branch build has a higher count than `main`: sideloaded, it blocks Play's next update of the staging app (Android refuses a lower code) until it is uninstalled. The same holds once for a staging app sideloaded before Play: its release-based code (`10000` for 0.10.0) is higher than the count. +- **The staging app** (`APP_VARIANT=staging`) uses the build time instead: minutes since 2020-01-01 UTC, set by Build phone app. Every build is higher than the one before, whatever ref it comes from, so a build of an older tag installs on the phone as an ordinary update (data kept): staging can be rolled back, server and app. The version name and the commit in the Setup footer say what a build contains. +- **Testing a release on staging**: after `scripts/release.sh`, the deployment's staging Update with `ref: vX.Y.Z` puts exactly that tag on staging (server and app) without moving the deployment's pin; its production Release then ships the same tag. ## Cutting a release diff --git a/docs/self-hosting.md b/docs/self-hosting.md index c2dc16a..ce88222 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -136,7 +136,7 @@ Google Play delivers the app instead of sideloading, starting with the **interna - **Requirements**, checked by the workflow's "Play requirements" step: - Target API level 36 or higher: Play has rejected new apps and updates below it since 31 August 2026. The step fails below. - 16 KB page size: native libraries' load segments aligned to 16 KB. The step reports any library that is not, as a warning for now. -- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app counts commits instead, so every build of `main` is a new version (it needs the full history, which a run with `ref` has). +- **Version codes.** Play takes each version code once per app. Production's grows with each release (`0.10.0` → `10000`, [releasing](releasing.md)), so production uploads once per release. The staging app uses the build time instead (minutes since 2020), so every build is a new, higher version, whatever it is built from: an older tag installs as an ordinary update, which makes rolling staging back on a phone possible. - **Signing.** Play signs what it delivers (Play App Signing). Let Google generate each app's signing key (the Console's default on the first release); your deployment's key from `scripts/keystore.sh` becomes the app's **upload key**: Build phone app signs every bundle with it, and Play accepts only bundles signed with it. A lost upload key can be reset through Google, so no app is ever stuck. The price: an APK from Build phone app (signed with your key) and the app from Play (signed with Google's) cannot update each other, so a phone has one or the other; uninstall to switch. (Uploading your own key as the signing key with Google's PEPK tool keeps them interchangeable, but a lost key then means an app that can never be updated.) - **The first bundle of each app** is uploaded by hand in the Play Console: the API can neither create an app nor choose its signing key. Play Console → Create app → the package (`org.openclapper`, `org.openclapper.staging`) → Testing → Internal testing → Create release → keep "Google-generated key" → the `.aab` from Build phone app, which registers its key as the upload key. Then add the testers' emails and open the opt-in link on each phone once. - **Uploads from the workflow.** Google Cloud Console → a project → IAM → Service accounts → create one and a JSON key for it. Play Console → Users and permissions → invite the service account's email with "Release to testing tracks" on the app (on each app). Store the JSON as the secret `PLAY_SERVICE_ACCOUNT_JSON` (in each GitHub environment that uploads). Then Build phone app with `track: internal` uploads the bundle after the build (`scripts/play-upload.mjs`: Google's androidpublisher API with the service account, no dependency). A version code Play already has is a notice, not a failure. An app that was never published takes draft releases only: the run says so, and you roll the release out in the Console once. A newly invited service account may be refused for up to a day.