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`.
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
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()