From dc21e293750f1f29c8cdd0f6b41eb2306ed880c5 Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 9 Jul 2026 21:50:23 -0300 Subject: [PATCH 1/3] Fix broken build-instructions link in README README linked to BUILDING.md, which does not exist. The build instructions live in RELEASE.md; point the link there. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e842e7d0c..910ecf3b51 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Please search for any [existing issues](https://github.com/session-foundation/se ## Build instructions -Build instructions can be found in [BUILDING.md](BUILDING.md). +Build instructions can be found in [RELEASE.md](RELEASE.md). ## Translations From cf58c3f341a0fea04c43c95fe5b88a576afce10c Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 9 Jul 2026 21:50:23 -0300 Subject: [PATCH 2/3] Add CLAUDE.md with build, run, and localization notes Capture the toolchain, flavors/build types, the play build + emulator install commands, and the Crowdin "strings.xml is generated, don't hand-edit" gotcha (pointing at ARCHITECTURE.md), so future sessions can pick it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..b7c8a4ed48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# Session Android — build & run notes + +Standard Gradle/Android project. See `ARCHITECTURE.md` for architecture and +`RELEASE.md` for the full build/release process. + +## Toolchain + +- JDK 17+ (JDK 21 works). +- Android SDK via `ANDROID_HOME` (set here to `~/Android/Sdk`) or `sdk.dir` in + `local.properties`. +- `./gradlew` wrapper; `adb` at `/usr/bin/adb`; the emulator binary is at + `$ANDROID_HOME/emulator/emulator` (not on PATH). + +## Flavors and build types + +- Distribution flavors: `play`, `fdroid`, `website`, `huawei`. + - `play` and `fdroid` are the Firebase-enabled variants (`firebaseEnabledVariants` + in `app/build.gradle.kts`): both include Firebase Cloud Messaging for push, + but exclude firebase core/analytics/measurement. `huawei` uses HMS push; + `website` uses no-op push wiring. +- Build types: `debug`, `release`, `releaseWithDebugMenu`, `qa`, `automaticQa`. +- CI (`.github/workflows/build_and_test.yml`) builds the `qa` type across all + flavors plus a `test` job. + +## Build / install / run (play, for local testing) + +```sh +./gradlew :app:assemblePlayDebug # build APK only (no device needed) +./gradlew :app:installPlayDebug # build + install to a running device/emulator +``` + +Huawei builds need the flag, e.g. `./gradlew assembleHuaweiDebug -Phuawei`. + +### Emulator + +Available AVDs (`$ANDROID_HOME/emulator/emulator -list-avds`) include +`Pixel_6_API_33`. Start one detached, then install: + +```sh +"$ANDROID_HOME/emulator/emulator" -avd Pixel_6_API_33 & +adb wait-for-device # then poll sys.boot_completed +./gradlew :app:installPlayDebug +``` + +After install, confirm the package id (it is suffixed per flavor/build type): +`adb shell pm list packages | grep loki`, then launch it. + +## Localization / strings — IMPORTANT + +`app/src/main/res/values*/strings.xml` and `NonTranslatableStringConstants.kt` +are **generated from Crowdin — do not hand-edit them.** Text changes are made in +Crowdin and synced in by the `session-foundation/session-shared-scripts` +pipeline. See `ARCHITECTURE.md` §13 (Localization and Translations) for the full +flow, the approved-only export gate, and the ordering to ship a text change. + +## Branching + +- `master` = production; `dev` = integration branch; PRs target `dev`. +- Crowdin translation PRs land on branch `feature/update-crowdin-translations` → `dev`. From 6f21e4bd1b2ef3591adcb27e5921b28861d3a83e Mon Sep 17 00:00:00 2001 From: Jason Rhinelander Date: Thu, 9 Jul 2026 21:50:24 -0300 Subject: [PATCH 3/3] Add release-pipeline automation scripts Automate the mechanical, error-prone parts of cutting a Session Android release, and move all signing/publishing credentials off disk into the OS keyring. The scripts are plain Python 3 (no uv/venv bootstrap) and shell out to git, gh, gradlew, and fdroid. ## Credentials (OS keyring) Signing keys and the Play API key no longer sit in plaintext files. They are stored once in the OS keyring -- cross-platform via the `keyring` library (macOS Keychain, Linux Secret Service, ...) -- and read at run time: - release-creds the signing-keystore TOML (play / huawei / fdroid) - play-service-account the Play Developer API service-account JSON key _keyring.py is the shared helper, with a set/get/del CLI: python3 scripts/_keyring.py set release-creds < release-creds.toml ## The tools - prepare-release.py X.Y.Z [--from BRANCH] Forks release/X.Y.Z from master (or --from), bumps canonicalVersionCode and canonicalVersionName in app/build.gradle.kts, commits, pushes, and opens a GitHub draft release with placeholder notes -- then stops so you drive the merges yourself. --dry-run previews; --yes skips the prompt. - update-release-notes.py X.Y.Z (Re)generates the draft's changelog using GitHub's own release-notes generator for the finalized branch and overwrites the draft body. Run it after the merges and before signing (it replaces the whole body). - build-and-release.py [--build-only] Builds every flavor -- play (AAB + split APKs), fdroid (APKs), huawei (universal APK) -- signed with the keyring creds; opens the automated F-Droid PR against session-fdroid; and uploads the play/huawei artifacts to the GitHub draft. --build-only skips the F-Droid PR and the GitHub upload, for a local build/signing check. - play-store.py {status | upload | rollout} Play Developer API tool: status list each track's releases (version code, status, rollout %) upload [AAB] --track T [...] upload the AAB as a draft (default), a full rollout (--submit), or a staged rollout (--rollout 0.1). Only the AAB is sent; Play derives the device splits. rollout {FRACTION|complete|halt} change a live staged rollout without re-uploading -- bump the %, go to 100%, or pause it. ## A release, end to end # land the change on dev via its PR first, then: scripts/prepare-release.py 1.33.5 # branch + version bump + draft git switch release/1.33.5 && git merge origin/dev && git push scripts/update-release-notes.py 1.33.5 # changelog onto the draft scripts/build-and-release.py # build all flavors, F-Droid PR, upload to draft # sign files + append signature block scripts/play-store.py upload --track production --submit # AAB -> Play # human go-live steps: gh release edit 1.33.5 --draft=false # publish the GitHub release (creates the tag) scripts/play-store.py rollout ... # ramp the Play rollout if staged # + click Publish in the Play Console, merge the F-Droid PR, # + merge release/1.33.5 into master ## Play API gaps worth knowing - With managed publishing on, a submitted Play release is reviewed but held for a manual Publish in the console; there is no API for that final publish, nor for querying review status (status shows rollout state only). - Play has no native timed rollout ramp -- `rollout` is the primitive to script one (e.g. a daily cron that bumps the fraction). - Huawei has no store automation: its APK is attached to the GitHub release only; any AppGallery upload is manual. Co-Authored-By: Claude Opus 4.8 (1M context) --- RELEASE.md | 105 ++++++++++++--- scripts/_keyring.py | 61 +++++++++ scripts/build-and-release.py | 47 ++++--- scripts/play-store.py | 227 ++++++++++++++++++++++++++++++++ scripts/prepare-release.py | 156 ++++++++++++++++++++++ scripts/update-release-notes.py | 91 +++++++++++++ 6 files changed, 648 insertions(+), 39 deletions(-) create mode 100644 scripts/_keyring.py create mode 100755 scripts/play-store.py create mode 100755 scripts/prepare-release.py create mode 100755 scripts/update-release-notes.py diff --git a/RELEASE.md b/RELEASE.md index 2c2aa13437..aa877df8d6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -63,9 +63,14 @@ Gradle properties: assembleFdroidRelease ``` -The keystore file can be recreated from `release-creds.toml` — the `keystore` field in each -section is the JKS file base64-encoded, so it can be decoded back to a `.jks` file using any -standard base64 tool. +The keystore file can be recreated from the stored credentials — the `keystore` field in each +section is the keystore file base64-encoded, so it can be decoded back to a keystore file with +any standard base64 tool, e.g.: + +```sh +python3 scripts/_keyring.py get release-creds \ + | python3 -c 'import sys,tomllib,base64; open("keystore.jks","wb").write(base64.b64decode(tomllib.loads(sys.stdin.read())["build"]["play"]["keystore"]))' +``` --- @@ -73,19 +78,22 @@ standard base64 tool. > **Quick checklist** > -> 1. [ ] Create a `release/MAJOR.MINOR.PATCH` branch from `master` -> 2. [ ] Merge `dev` into the release branch (full release), or cherry-pick the relevant patch commits -> 3. [ ] Bump the version code -> 4. [ ] Create a GitHub release draft for the version (e.g. `1.23.4`) in this repository -> 5. [ ] Obtain `release-creds.toml` from a project maintainer and place it in the project root -> 6. [ ] Run `./scripts/build-and-release.py` from the release branch -> 7. [ ] Upload the AAB bundle to the Play Store +> 1. [ ] `scripts/prepare-release.py MAJOR.MINOR.PATCH` — creates the release branch off +> `master`, bumps the version, pushes, and creates the GitHub draft release +> 2. [ ] Merge `dev` into the release branch (full release) or cherry-pick patch commits, then push +> 3. [ ] `scripts/update-release-notes.py MAJOR.MINOR.PATCH` — (re)generate the draft's changelog +> 4. [ ] One-time: store the signing credentials in your keyring (see Credentials below) +> 5. [ ] `scripts/build-and-release.py` from the release branch — builds, opens the F-Droid PR, +> and uploads artifacts to the GitHub draft +> 6. [ ] Sign the release files and append the signature block (your release-signing script) +> 7. [ ] `scripts/play-store.py upload [--track …] [--submit|--rollout F]` — upload the AAB to Play > 8. [ ] Review and merge the automated F-Droid PR in `session-foundation/session-fdroid` -> 9. [ ] Review and publish the GitHub release draft +> 9. [ ] Publish the GitHub draft: `gh release edit MAJOR.MINOR.PATCH --draft=false` -Steps 6–9 are explained in detail below. Steps 4 and 5 must be completed **before** running -the script — if no release draft exists the artifact upload is silently skipped, and without -the credentials file the script will exit immediately. +The GitHub draft (step 1) and the keyring credentials (step 4) must exist **before** +`build-and-release.py` runs: without a draft the artifact upload is silently skipped, and +without the credentials the script exits immediately. Run `update-release-notes.py` (step 3) +before the signing step (step 6), since regenerating the notes overwrites the whole release body. ### Branching strategy @@ -119,17 +127,24 @@ Once the branch is ready, bump the version code in `app/build.gradle` (the `vers ### Prerequisites -- [**uv**](https://docs.astral.sh/uv/) — the script uses `uv run` to manage its Python - dependencies (`fdroidserver`) automatically +- **fdroidserver** — provides the `fdroid` command the script calls. Install it via your + system package manager: `apt install fdroidserver` (Debian/Ubuntu), + `brew install fdroidserver` (Homebrew), or `port install fdroidserver` (MacPorts). + Other methods (PPA, pip, Docker, …): - [**gh**](https://cli.github.com/) — GitHub CLI, authenticated and with access to `session-foundation/session-android` and `session-foundation/session-fdroid` - **Android SDK** — either `ANDROID_HOME` set in the environment, or `sdk.dir` defined in `local.properties`. This would have been set up for you if you have opened the project in Android Studio. +- **keyring** — signing/publishing credentials are read from the OS keyring, not from disk. + Install: `apt install python3-keyring` (Linux) or `pip install keyring` (macOS). +- **Google API libraries** (only for `play-store.py`): + `apt install python3-google-auth python3-googleapi` + (or `pip install google-auth google-api-python-client`). -### Credentials file +### Credentials (OS keyring) -The script requires a `release-creds.toml` file in the project root. This file is not -committed to the repository — ask a project maintainer for it. Its structure is: +The signing credentials are a TOML document (ask a project maintainer for it) with this +structure: ```toml [build.play] @@ -151,6 +166,17 @@ key_alias = "" key_password = "" ``` +Rather than leaving that file on disk, store its contents in the OS keyring once, then delete +the plaintext file: + +```sh +python3 scripts/_keyring.py set release-creds < release-creds.toml +rm release-creds.toml +``` + +`build-and-release.py` reads it back from the keyring at build time. (The keyring is +cross-platform via the `keyring` library — macOS Keychain, Linux Secret Service, etc.) + ### Running the script From the project root, run: @@ -196,6 +222,47 @@ For example, to perform builds without publishing anything: --- +## Publishing to the Play Store + +`build-and-release.py` uploads the AAB to the *GitHub* release draft, not to Google Play. +`scripts/play-store.py` handles Play via the Play Developer API, with three subcommands: + +- `play-store.py status` — show each track's releases (version code, status, rollout %) +- `play-store.py upload [AAB] --track T [--submit | --rollout F]` — upload an AAB +- `play-store.py rollout {FRACTION|complete|halt} --track T` — change a staged rollout without re-uploading + +One-time setup: + +- Create a Play service account (Google Cloud Console → IAM & Admin → Service Accounts), + download its JSON key, then grant it access in Play Console → **Users & permissions** + (invite the service-account email; give it **Release to production** + **Release to testing + tracks**). Store the key in the keyring and delete the plaintext: + ```sh + python3 scripts/_keyring.py set play-service-account < service-account.json + rm service-account.json + ``` +- Install the Google API libraries (see Prerequisites). + +Then, after `build-and-release.py` has produced the AAB: + +```sh +scripts/play-store.py upload # draft on the internal track +scripts/play-store.py upload --track production --submit # full rollout +scripts/play-store.py upload --track production --rollout 0.1 # staged: 10% of users +scripts/play-store.py status # check what landed +``` + +`upload` with no flag leaves the release a **draft** (parked, not submitted). `--submit` +submits a full rollout; `--rollout F` submits a staged rollout to fraction `F` — then ramp it +with `play-store.py rollout …` (e.g. from cron; Play has no native auto-ramp). The AAB path +defaults to the play release bundle produced by `build-and-release.py`. + +**Managed publishing:** with it on (Play Console → Publishing overview), a submitted release is +reviewed but **held until you click Publish** in the console — there is no API for that final +publish step, nor for querying review status. Keep it on for release control. + +--- + ## Contributing code Code contributions should be submitted via GitHub as pull requests from feature branches, diff --git a/scripts/_keyring.py b/scripts/_keyring.py new file mode 100644 index 0000000000..cfcbe3ee57 --- /dev/null +++ b/scripts/_keyring.py @@ -0,0 +1,61 @@ +"""Cross-platform access to the OS keyring via the `keyring` library. + +The `keyring` package selects the right backend per operating system: + - macOS: Keychain + - Linux: Secret Service (GNOME Keyring / KWallet, via secretstorage) + - Windows: Credential Locker (note: small blob size limit; not recommended + for the large base64 signing creds) + +Install it with: + Debian/Ubuntu: apt install python3-keyring + macOS: pip install keyring (built-in Keychain backend) + +Secrets are addressed by (SERVICE, name). Store one from a file -- which +preserves multi-line values -- using this module's CLI: + python3 scripts/_keyring.py set release-creds < release-creds.toml +then delete the plaintext file. `get`/`del` subcommands are also provided. +""" +import sys + +SERVICE = "session-android" + + +def _keyring(): + try: + import keyring + return keyring + except ImportError: + sys.exit("Python `keyring` library not found. Install it " + "(Debian/Ubuntu: `apt install python3-keyring`; macOS: `pip install keyring`).") + + +def get_secret(name, *, what): + """Return the secret stored under (SERVICE, name), or exit with instructions.""" + value = _keyring().get_password(SERVICE, name) + if not value: + sys.exit(f"{what} not found in the keyring (service '{SERVICE}', name '{name}').\n" + f"Store it once (reads the value from stdin, then delete the plaintext file):\n" + f" python3 scripts/_keyring.py set {name} < /path/to/secret") + return value + + +def _main(argv): + if len(argv) != 3 or argv[1] not in ("set", "get", "del"): + sys.exit("usage: _keyring.py {set|get|del} (set reads the value from stdin)") + action, name = argv[1], argv[2] + kr = _keyring() + if action == "set": + kr.set_password(SERVICE, name, sys.stdin.read()) + print(f"Stored secret (service '{SERVICE}', name '{name}').") + elif action == "get": + value = kr.get_password(SERVICE, name) + if value is None: + sys.exit(f"No secret for (service '{SERVICE}', name '{name}').") + sys.stdout.write(value) + else: # del + kr.delete_password(SERVICE, name) + print(f"Deleted secret (service '{SERVICE}', name '{name}').") + + +if __name__ == "__main__": + _main(sys.argv) diff --git a/scripts/build-and-release.py b/scripts/build-and-release.py index d4fba83606..3a86a13575 100755 --- a/scripts/build-and-release.py +++ b/scripts/build-and-release.py @@ -1,10 +1,4 @@ -#!/usr/bin/env -S uv run --script - -# /// script -# dependencies = [ -# "fdroidserver", -# ] -# /// +#!/usr/bin/env python3 import subprocess import json @@ -20,6 +14,8 @@ import glob import argparse +from _keyring import get_secret + # Number of versions to keep in the fdroid repo. Will remove all the older versions. KEEP_FDROID_VERSIONS = 4 @@ -100,7 +96,7 @@ def build_releases(project_root: str, project_root = os.path.dirname(sys.path[0]) build_dir = os.path.join(project_root, 'build') -credentials_file_path = os.path.join(project_root, 'release-creds.toml') +RELEASE_CREDS_KEYRING_NAME = 'release-creds' fdroid_repo_path = os.path.join(build_dir, 'fdroidrepo') def detect_android_sdk() -> str: @@ -222,10 +218,16 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent print('Committing the changes...') subprocess.run(f'git add . && git commit -am "Prepare for release {build.version_name}"', shell=True, check=True, cwd=fdroid_workspace) - # Create Pull Request for releases + # Push the branch explicitly to origin (session-foundation/session-fdroid) first, so + # `gh pr create` has nothing to push and won't prompt. `gh repo clone` adds an `upstream` + # remote (oxen-io), so with two remotes gh otherwise can't decide where to push the head. + print('Pushing the release branch...') + subprocess.run(f'git push -u origin {branch_name} --force-with-lease', shell=True, check=True, cwd=fdroid_workspace) + + # Create Pull Request for releases (--head names the already-pushed branch => no prompt) print('Creating a pull request...') subprocess.run(f'''\ - gh pr create --base master \ + gh pr create --base master --head {branch_name} \ --title "Release {build.version_name}" \ -R session-foundation/session-fdroid \ --body "This is an automated release preparation for Release {build.version_name}. Human beings are still required to approve and merge this PR."\ @@ -248,16 +250,21 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent # Make sure fdroid command is available if shutil.which('fdroid') is None: - print('`fdroid` command not found. It is required to automate fdroid releases. Please install it from https://f-droid.org/', file=sys.stderr) - sys.exit(1) - -# Make sure credentials file exists -if not os.path.isfile(credentials_file_path): - print(f'Credentials file not found at {credentials_file_path}. You should ask the project maintainer for the file.', file=sys.stderr) + print('`fdroid` command not found. Install fdroidserver via your system package manager:\n' + ' Debian/Ubuntu: apt install fdroidserver\n' + ' Homebrew: brew install fdroidserver\n' + ' MacPorts: port install fdroidserver\n' + 'Other methods: https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/', + file=sys.stderr) sys.exit(1) -with open(credentials_file_path, 'rb') as f: - credentials = tomllib.load(f) +# Load signing credentials (a TOML blob) from the OS keyring, so they never sit +# on disk in plaintext. Store them once with: +# python3 scripts/_keyring.py set release-creds < release-creds.toml +# then delete the plaintext file. +credentials = tomllib.loads(get_secret( + RELEASE_CREDS_KEYRING_NAME, + what='Release signing credentials (release-creds.toml contents)')) # Make sure build folder exists if not os.path.isdir(build_dir): @@ -304,13 +311,13 @@ def update_fdroid(build: BuildResult, fdroid_workspace: str, creds: BuildCredent # If the a github release draft exists, upload the apks to the release if not args.build_only: try: - release_info = json.loads(subprocess.check_output(f'gh release view --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) + release_info = json.loads(subprocess.check_output(f'gh release view -R session-foundation/session-android --json isDraft {play_build_result.version_name}', shell=True, cwd=project_root)) if release_info['isDraft'] == True: print(f'Uploading build artifact to the release {play_build_result.version_name} draft...') files_to_upload = [*play_build_result.apk_paths, play_build_result.bundle_path, *huawei_build_result.apk_paths] - upload_commands = ['gh', 'release', 'upload', play_build_result.version_name, '--clobber', *files_to_upload] + upload_commands = ['gh', 'release', 'upload', '-R', 'session-foundation/session-android', play_build_result.version_name, '--clobber', *files_to_upload] subprocess.run(upload_commands, shell=False, cwd=project_root, check=True) print('Successfully uploaded these files to the draft release: ') diff --git a/scripts/play-store.py b/scripts/play-store.py new file mode 100755 index 0000000000..63e16bc56d --- /dev/null +++ b/scripts/play-store.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Manage Session's Google Play release from the command line. + +Talks to the Google Play Developer API using a service-account key read from the +OS keyring (see _keyring.py). Subcommands: + + status show each track's releases (version code, status, rollout %) + upload [AAB] upload an AAB to a track + rollout {FRACTION|complete|halt} change the current staged rollout on a track (no re-upload) + +Prerequisites: + - apt install python3-google-auth python3-googleapi (or the pip equivalents) + - the `keyring` library + an unlocked keyring, with the key stored once: + python3 scripts/_keyring.py set play-service-account < service-account.json + (then delete the JSON file) + +Note: Play has no API to auto-ramp a staged rollout, and with managed publishing ON a +submitted release is reviewed but held until you click Publish in the Play Console +(there is no API for that final publish). Use `rollout` (e.g. from cron) to ramp manually. + +Examples: + scripts/play-store.py status + scripts/play-store.py upload # draft on the internal track + scripts/play-store.py upload --track production --submit # full rollout (submitted) + scripts/play-store.py upload --track production --rollout 0.1 # start a 10% staged rollout + scripts/play-store.py rollout --track production 0.5 # bump the staged rollout to 50% + scripts/play-store.py rollout --track production complete # go to 100% + scripts/play-store.py rollout --track production halt # pause the rollout +""" +import argparse +import json +import os +import sys + +from _keyring import get_secret + +KEYRING_NAME = "play-service-account" +DEFAULT_PACKAGE = "network.loki.messenger" +DEFAULT_AAB = "app/build/outputs/bundle/playRelease/app-play-release.aab" +SCOPES = ["https://www.googleapis.com/auth/androidpublisher"] +TRACKS = ["internal", "alpha", "beta", "production"] + + +def play_service(): + """Build an authenticated androidpublisher client (imports + keyring read here).""" + try: + from google.oauth2 import service_account + from googleapiclient.discovery import build + except ImportError: + sys.exit("Missing Google API libraries. Install them, e.g.:\n" + " apt install python3-google-auth python3-googleapi") + try: + info = json.loads(get_secret(KEYRING_NAME, what="Play service-account JSON key")) + except json.JSONDecodeError as e: + sys.exit(f"Stored Play service-account secret is not valid JSON: {e}") + creds = service_account.Credentials.from_service_account_info(info, scopes=SCOPES) + return build("androidpublisher", "v3", credentials=creds, cache_discovery=False) + + +def in_edit(service, pkg, fn, *, commit): + """Run fn(edit_id) inside a Play edit; commit on success, always abandon on failure.""" + edit_id = service.edits().insert(body={}, packageName=pkg).execute()["id"] + try: + result = fn(edit_id) + except BaseException: + try: + service.edits().delete(packageName=pkg, editId=edit_id).execute() + except Exception: + pass + raise + if commit: + service.edits().commit(packageName=pkg, editId=edit_id).execute() + else: + service.edits().delete(packageName=pkg, editId=edit_id).execute() + return result + + +def fmt_release(rel): + status = rel.get("status", "?") + frac = rel.get("userFraction") + frac_s = f" @ {frac:.0%}" if frac is not None else "" + vcs = ",".join(str(v) for v in (rel.get("versionCodes") or [])) + name = rel.get("name", "") + return f"{status}{frac_s} versionCodes=[{vcs}] {name}".rstrip() + + +def cmd_status(pkg, args): + service = play_service() + data = in_edit(service, pkg, + lambda eid: service.edits().tracks().list(packageName=pkg, editId=eid).execute(), + commit=False) + tracks = [t for t in data.get("tracks", []) if t.get("releases")] + if not tracks: + print("No track releases found.") + return + for t in tracks: + print(f"{t['track']}:") + for rel in t["releases"]: + print(f" {fmt_release(rel)}") + + +def cmd_upload(pkg, args): + if not os.path.isfile(args.aab): + sys.exit(f"AAB not found: {args.aab}\nBuild it first (build-and-release.py) or pass the path.") + + if args.rollout is not None: + if not (0.0 < args.rollout < 1.0): + sys.exit("--rollout must be strictly between 0 and 1 (use --submit for a full rollout).") + status, desc = "inProgress", f"staged rollout to {args.rollout:.0%} (submitted)" + elif args.submit: + status, desc = "completed", "full rollout (submitted)" + else: + status, desc = "draft", "draft, not submitted" + + print(f"AAB: {args.aab}\nTrack: {args.track}\nStatus: {status} -- {desc}") + if args.dry_run: + print("\nDry run -- not uploading.") + return + + service = play_service() + from googleapiclient.http import MediaFileUpload + + def fn(eid): + media = MediaFileUpload(args.aab, mimetype="application/octet-stream", resumable=True) + print("Uploading bundle (this can take a while)...") + vc = service.edits().bundles().upload( + packageName=pkg, editId=eid, media_body=media).execute()["versionCode"] + print(f"Uploaded versionCode {vc}.") + release = {"versionCodes": [str(vc)], "status": status} + if status == "inProgress": + release["userFraction"] = args.rollout + service.edits().tracks().update(packageName=pkg, editId=eid, track=args.track, + body={"releases": [release]}).execute() + return vc + + vc = in_edit(service, pkg, fn, commit=True) + if status == "draft": + print(f"\nDone. versionCode {vc} on '{args.track}' as a DRAFT -- submit it in the console.") + else: + print(f"\nDone. versionCode {vc} submitted on '{args.track}' ({desc}).\n" + f"With managed publishing on, it is held for your manual Publish in the console.") + + +def cmd_rollout(pkg, args): + if args.action == "complete": + new_status, new_fraction = "completed", None + elif args.action == "halt": + new_status, new_fraction = "halted", None + else: + try: + new_fraction = float(args.action) + except ValueError: + sys.exit("rollout target must be a fraction like 0.25, or 'complete', or 'halt'.") + if not (0.0 < new_fraction < 1.0): + sys.exit("rollout fraction must be strictly between 0 and 1 (use 'complete' for 100%).") + new_status = "inProgress" + + service = play_service() + + def get_active(eid): + track = service.edits().tracks().get(packageName=pkg, editId=eid, track=args.track).execute() + return track.get("releases", []) + + if args.dry_run: + rels = in_edit(service, pkg, get_active, commit=False) + print(f"Current releases on '{args.track}':") + for r in rels: + print(f" {fmt_release(r)}") + target = new_status + (f" @ {new_fraction:.0%}" if new_fraction is not None else "") + print(f"\nDry run -- would set the active rollout to: {target}") + return + + def fn(eid): + active = [r for r in get_active(eid) if r.get("status") in ("inProgress", "halted")] + if not active: + sys.exit(f"No in-progress/halted rollout on track '{args.track}' to change.\n" + f"Run `status` to check, or `upload --rollout` to start one.") + rel = active[0] + rel["status"] = new_status + if new_status == "inProgress": + rel["userFraction"] = new_fraction + else: + rel.pop("userFraction", None) + service.edits().tracks().update(packageName=pkg, editId=eid, track=args.track, + body={"releases": [rel]}).execute() + return rel + + rel = in_edit(service, pkg, fn, commit=True) + print(f"Done. Track '{args.track}' release now: {fmt_release(rel)}") + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--package", default=DEFAULT_PACKAGE, + help=f"application id (default: {DEFAULT_PACKAGE})") + sub = p.add_subparsers(dest="command", required=True) + + s = sub.add_parser("status", help="show each track's releases and rollout state") + s.set_defaults(func=cmd_status) + + u = sub.add_parser("upload", help="upload an AAB to a track") + u.add_argument("aab", nargs="?", default=DEFAULT_AAB, help=f"path to the AAB (default: {DEFAULT_AAB})") + u.add_argument("--track", default="internal", choices=TRACKS, help="release track (default: internal)") + grp = u.add_mutually_exclusive_group() + grp.add_argument("--submit", action="store_true", + help="submit as a full rollout (status=completed); default leaves a draft") + grp.add_argument("--rollout", type=float, metavar="FRACTION", + help="submit as a staged rollout to this fraction of users (0 < FRACTION < 1)") + u.add_argument("--dry-run", action="store_true", help="don't upload; just show what would happen") + u.set_defaults(func=cmd_upload) + + r = sub.add_parser("rollout", help="change the current staged rollout on a track (no re-upload)") + r.add_argument("action", metavar="FRACTION|complete|halt", + help="new rollout fraction (0{new_code}", text, count=1) + new_text = NAME_RE.sub(rf"\g<1>{version}\g<3>", new_text, count=1) + if new_text == text: + sys.exit(f"Version bump produced no change in {GRADLE}; aborting.") + with open(GRADLE, "w") as f: + f.write(new_text) + + run(["git", "add", GRADLE]) + run(["git", "commit", "-m", f"Bump version to {version} ({new_code})"]) + run(["git", "push", "-u", REMOTE, branch]) + run(["gh", "release", "create", version, "-R", REPO, "--draft", "--target", branch, + "--title", version, "--notes", PLACEHOLDER_NOTES]) + + print(f""" +Done. {branch} is pushed, version bumped to {version} ({new_code}), and a draft +GitHub release '{version}' exists. + +Next steps (yours): + 1. Merge dev or cherry-pick into {branch} (you're already on it), then push: + git merge dev # full release + git cherry-pick ... # patch release + git push + 2. Regenerate the draft's notes from the finalized branch: + scripts/update-release-notes.py {version} + 3. Build + upload artifacts: + scripts/build-and-release.py + 4. Sign the release, then publish the draft: + gh release edit {version} --draft=false +""") + + +if __name__ == "__main__": + main() diff --git a/scripts/update-release-notes.py b/scripts/update-release-notes.py new file mode 100755 index 0000000000..f270428bbe --- /dev/null +++ b/scripts/update-release-notes.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Regenerate a release's changelog notes and write them onto its GitHub draft. + +Uses GitHub's own release-notes generator (the "What's Changed" list built from +merged PR titles) for the release branch as it currently stands, and overwrites +the draft release's body with the result. Run it after you have merged / +cherry-picked everything into the release branch, and re-run it whenever the +branch changes. + +IMPORTANT: this overwrites the entire release body. Run it BEFORE the PGP +signing step that appends the signature block, or that block will be clobbered. + +Usage: + scripts/update-release-notes.py 1.34.0 + scripts/update-release-notes.py 1.34.0 --target release/1.34.0 + scripts/update-release-notes.py 1.34.0 --dry-run + scripts/update-release-notes.py 1.34.0 --force # allow editing a published (non-draft) release +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile + +REPO = "session-foundation/session-android" # explicit: repo has several remotes, gh can't auto-pick + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("version", help="release version / tag, e.g. 1.34.0") + p.add_argument("--target", default=None, + help="branch/commitish to generate notes for (default: release/)") + p.add_argument("--dry-run", action="store_true", + help="print the generated notes without writing them") + p.add_argument("--force", action="store_true", + help="proceed even if the release is already published (not a draft)") + args = p.parse_args() + + if shutil.which("gh") is None: + sys.exit("`gh` (GitHub CLI) not found; install it from https://cli.github.com/") + + # Run from the repo root so gh resolves the correct repo. + os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + version = args.version + target = args.target or f"release/{version}" + + # The release must already exist (created by prepare-release.py). + view = subprocess.run(["gh", "release", "view", version, "-R", REPO, "--json", "isDraft"], + capture_output=True, text=True) + if view.returncode != 0: + sys.exit(f"No release '{version}' found. Create it first with prepare-release.py.") + if not json.loads(view.stdout)["isDraft"] and not args.force: + sys.exit(f"Release '{version}' is already published. Re-run with --force to overwrite " + f"its notes (this will also wipe any appended signature block).") + + # Ask GitHub to build the notes for the branch as it stands now. + gen = subprocess.run( + ["gh", "api", "--method", "POST", f"repos/{REPO}/releases/generate-notes", + "-f", f"tag_name={version}", "-f", f"target_commitish={target}", "--jq", ".body"], + capture_output=True, text=True) + if gen.returncode != 0: + sys.exit(f"Failed to generate notes:\n{gen.stderr.strip()}") + body = gen.stdout.strip() + if not body: + sys.exit("GitHub returned empty notes -- check that the target branch exists and has " + "commits beyond the previous tag.") + + if args.dry_run: + print(f"--- generated notes for {version} (target {target}) ---\n") + print(body) + return + + # Write via a temp file to avoid any argv quoting issues with the body. + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f: + f.write(body) + notes_file = f.name + try: + subprocess.run(["gh", "release", "edit", version, "-R", REPO, "--notes-file", notes_file], check=True) + finally: + os.remove(notes_file) + + print(f"Updated the notes on draft release '{version}' (generated from {target}).") + print("Reminder: run this before the PGP signing step, since it overwrites the whole body.") + + +if __name__ == "__main__": + main()