A push to main builds, signs, and publishes. There is no separate release
command: .github/workflows/build.yml produces two artifacts from one Gradle
invocation and ships them to two places.
| Artifact | Where it goes |
|---|---|
app-release.aab |
Play, internal testing track |
detour-<version>.apk |
GitHub release, for sideloading |
The GitHub release is cut first and Play second. Since the in-app update check landed, that release is how every sideloaded install learns a new version exists, so a Play verdict — which is about store policy, not about whether the artifact is fit to sideload — no longer withholds it. A Play failure still fails the job.
Internal track means testers on that list get it within minutes with no review queue. Nothing here writes to production — promoting a build is a manual step in the Play Console.
The GitHub release body is the standing sideload/server boilerplate, followed
by GitHub’s auto-generated “What’s Changed” list (generate_release_notes:
true), grouped into New/Fixed via .github/release.yml. The app’s update
check reads that body back (UpdateCheck.Release.notes) and can show it in
Settings before a rider downloads. See CONTRIBUTING.md’s “Release notes”
section for why the list is shown verbatim rather than hand-written.
versionName in app/build.gradle.kts is yours to bump; it names the tag and
the GitHub release. Push twice without changing it and the second run replaces
that version’s GitHub release rather than adding a second one.
versionCode is stamped by CI and ignores the literal in the file:
versionCode = 1000 + GITHUB_RUN_NUMBER * 2
Play refuses any upload whose code isn’t higher than every code it has already
accepted. The run number only ever increases, which is the one property that
matters. The +1000 floor clears the codes already published as GitHub-release
APKs, so a sideloaded install still sees Play’s copy as newer.
The stride of 2 is vestigial: it left an odd slot for the watch bundle, which shared the phone’s applicationId and so needed a code of its own. The watch app is gone (#57), but the stride stays — halving it would emit codes Play has already accepted.
A local build keeps the literal versionCode — nothing about day-to-day
development changes.
The Play Developer API cannot create an application, and it cannot perform the first upload for one. That upload is manual, once:
Everything after that is CI’s.
CI authenticates as a Google Cloud service account that the Play Console has granted release permission.
Settings → Secrets and variables → Actions. The four signing secrets are
required on a push to main; the workflow fails with the missing names rather
than letting a build run and break later.
| Secret | Value |
|---|---|
RELEASE_KEYSTORE_B64 |
base64 -w0 release.jks |
RELEASE_KEYSTORE_PASSWORD |
keystore password |
RELEASE_KEY_ALIAS |
key alias inside the keystore |
RELEASE_KEY_PASSWORD |
key password |
PLAY_SERVICE_ACCOUNT_JSON |
the JSON key file’s full contents (only read when Play publishing is switched on, below) |
Play publishing is off by default. Until the Console account is verified and the app has had its first bundle uploaded by hand, every API upload comes back rejected — and a failed upload would take the whole run down with it, including the GitHub release the APKs are attached to. Off means the step is skipped, not that it fails.
Turn it on once Play is ready: Settings → Secrets and variables → Actions →
Variables → PUBLISH_TO_PLAY = true. With the variable unset, or set to
anything else, a push to main still builds, signs, tags and publishes the
GitHub release; it just doesn’t talk to Play. If the variable says true but
PLAY_SERVICE_ACCOUNT_JSON is empty, the run warns and skips rather than
failing.
The keystore is the upload key. Play App Signing re-signs with its own key before distributing, which is why an app installed from Play cannot be updated by an APK downloaded from GitHub — the signatures differ. Pick one source per device.