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.