Helper scripts for cutting a release, pushing builds to Google Play / F-Droid, and the Drone CI artifact plumbing.
Most of the Python scripts need gh (GitHub CLI) and read their secrets from the
OS keyring (see _keyring.py). build-and-release.py additionally needs
fdroid and the Google API client libraries; play-store.py needs the Google
API client libraries.
A full release is four scripts, run in sequence:
-
prepare-release.py <version>— forks arelease/<version>branch offmaster(or--from <branch>), commits acanonicalVersionCode/canonicalVersionNamebump, pushes it, and creates a GitHub draft release targeting the branch. Then it stops — you merge/cherry-pick the payload into the branch yourself.--dry-runshows the plan without touching anything. -
update-release-notes.py <version>— regenerates the draft's changelog from the branch's merged-PR titles (GitHub's own "What's Changed" generator) and overwrites the release body. Re-run whenever the branch changes; run it before the PGP-signing step, since it clobbers the whole body. -
build-and-release.py— builds the signed release artifacts for all distribution flavors:play(AAB + split APKs),fdroid, andhuawei. Signing creds come from the keyring (release-creds, a TOML blob). Unless--build-onlyis given it also opens an F-Droid release PR (session-foundation/session-fdroid) and uploads the artifacts to the GitHub draft release.--build-typeoverrides the defaultrelease. -
play-store.py upload— uploads the built AAB to Google Play (see below), then you publish the draft (gh release edit <version> --draft=false).
When you just want a real, signed build on the internal testing track — e.g. to exercise real Google Play Billing purchases against a dev Pro backend — skip the whole pipeline above (no release branch, version-name bump, notes, F-Droid PR, or GitHub draft). Because the build is release-signed and lives on a track, Play Billing works; as a license tester the purchases are real (backend validates the token) but uncharged.
-
Build a signed
playAAB, using the signing creds from the keyring:scripts/build-and-release.py --play-only
--play-onlyskips thefdroid/huaweiflavors and every upload/PR step, so it needs neitherfdroid/ghnor huawei creds — just therelease-credsentry. (If you'd rather not use the keyring at all, you can build the bundle straight from gradle with./gradlew :app:bundlePlayRelease -PSESSION_STORE_FILE=… -PSESSION_STORE_PASSWORD=… -PSESSION_KEY_ALIAS=… -PSESSION_KEY_PASSWORD=….) -
Make sure
versionCodeis higher than anything already on the track — check withplay-store.py status, and bumpcanonicalVersionCodeinapp/build.gradle.ktsif needed (a throwaway local bump is fine here). -
Upload to the internal track:
scripts/play-store.py upload # internal, as a draft scripts/play-store.py upload --track internal --submit # live to testers (no review)
-
Opt in as a tester (Play Console → internal testing → testers list + the opt-in URL) and install from Play on a device signed into that account.
Once one build is on the track you don't need to re-upload for every change: a
locally-built, identically-signed ./gradlew :app:installPlayRelease with the
same versionCode installs over adb and Play Billing keeps working — only
bump + re-upload when you deliberately move the versionCode.
For iterating on Pro (or anything needing Google's signature + Play Billing) against a running emulator, without touching a track at all. This is what "signed but mostly local" means: the app is built locally, but against the published libsession glue AAR and Google-signed via Internal App Sharing (which consumes no versionCode, so you can re-upload the same code freely).
-
Pick the glue source.
local.propertiesmay point the build at a locallibsession-util-androidcheckout (a composite build) viasession.libsession_util.project.path=…. To build against the published AAR instead — the production path, and what CI uses — comment that line out:#session.libsession_util.project.path=/…/libsession-util-androidPrefer the published AAR unless you're specifically testing local glue changes: the local composite can hit AGP/Kotlin-plugin mismatches, and the published AAR is the faithful production test.
-
Build the signed AAB. Use a JDK 17 or 21 — not 25 (a too-new JDK, e.g. an Android Studio bundled JBR that has moved to 25, breaks Gradle), so set
JAVA_HOMEexplicitly:JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 scripts/build-and-release.py --play-only # → app/build/outputs/bundle/playRelease/app-play-release.aab -
Push it onto the emulator via Internal App Sharing:
scripts/play-store.py share --open-emu # upload + fire the link at a running emulator--open-emuuploads, sends the install link to the running emulator, and polls the Play Store UI until the Install/Update button appears; then tap it on the emulator to install (the script surfaces the button but doesn't tap for you). The emulator must be a Play Store image (google_apis_playstore) for Internal App Sharing to work.Without
--open-emu, plainscripts/play-store.py sharejust prints the install link — open it in the emulator's own browser to land on the same Play Store install page.
play-store.py — command-line control of the Google Play release via the
Play Developer API (service-account key from the keyring, name
play-service-account). Subcommands:
status— list each track's releases (version codes, status, rollout %).upload [AAB]— upload a bundle to a track; defaults to theinternaltrack as a draft.--track {internal,alpha,beta,production},--submit(full rollout) or--rollout FRACTION(staged),--dry-run.rollout {FRACTION|complete|halt}— change an existing staged rollout without re-uploading (defaults to--track production).share [APK|AAB]— upload to Internal App Sharing and print an install link. Unlike a track release this consumes no versionCode (re-upload the same code freely) and needs no release/rollout/track management, so it's the fast loop for iterating on a build that still needs Google's signature (e.g. to exercise Play Billing). Add--open-emuto make it fully hands-off: it sends the link to a running Play Store emulator (launching agoogle_apis_playstoreAVD if none is running), falling back to printing theadbcommand if there isn't a single suitable emulator. Play processes each uploaded artifact before the link resolves to it — until then the link shows the installed build ("Open") not the new one ("Update"). There's no off-device readiness signal (the upload API exposes no status field, and thedownloadUrljust 302s to a Google login for any HTTP client), so--open-emupolls the on-device Play Store UI (viauiautomator): it fires the link, and if the Install/Update button isn't up yet it waits and re-fires — up to--open-emu-timeoutseconds (default 120), every--open-emu-interval(default 5). Re-firing also defeats any device-side cache. If it times out, the link is left on screen to re-fire manually. Whole loop:build-and-release.py --play-only && play-store.py share --open-emu.
_keyring.py — thin cross-platform wrapper over the keyring library. Used
as a helper by the release scripts (get_secret), and as a CLI to store secrets
once so they never sit on disk in plaintext:
python3 scripts/_keyring.py set release-creds < release-creds.toml
python3 scripts/_keyring.py set play-service-account < service-account.json
# ...then delete the plaintext files. `get` / `del` subcommands also exist.drone-static-upload.sh— packages the universal APK into asession-android-<tag-or-datetime-commit>-universal.tar.xzand SFTP-uploads it tooxen.rocks(needsSSH_KEYin the environment).drone-upload-exists.sh— for a PR build, pollsoxen.rocksfor up to 30 minutes for an already-uploaded artifact matching the PR's fork/branch, so CI can skip a redundant rebuild. Exits 0 if found.