Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/build-phone-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 6 additions & 5 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
3 changes: 2 additions & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading