From be5f1527194054ac1d5ba3e4f9c30c70a6a2f775 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 09:57:06 -0500 Subject: [PATCH 01/14] Let the Android release build take its signing and version code from the environment CI has no `keystore.properties`, so to sign a bundle for Play it needs another way to hand Gradle the keystore. Environment variables rather than CI writing the file: `Properties` reads a backslash or a colon in a password as syntax, and would sign with the wrong value or not at all. Each signing value falls back from `keystore.properties` to a `PUSHTHAT_*` variable, the file winning when both are set. `versionCode` reads `PUSHTHAT_VERSION_CODE` and keeps 8 for a local build. Co-Authored-By: Claude Opus 5.5 --- apps/android/app/build.gradle.kts | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 409a373..acc1602 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -13,8 +13,21 @@ val keystoreProperties = Properties().apply { val file = rootProject.file("keystore.properties") if (file.exists()) file.inputStream().use { load(it) } } -val hasReleaseSigning = listOf("storeFile", "storePassword", "keyAlias", "keyPassword") - .all { !keystoreProperties.getProperty(it).isNullOrBlank() } + +// CI has no keystore.properties, so each value can also come from the environment +// (.github/workflows/android.yml). Environment variables rather than CI writing the +// file: a password containing a backslash or a colon means something else to +// Properties, and would sign with the wrong value or not at all. The file wins +// when both are set, so a local build is never steered by a stray export. +fun signing(key: String, env: String): String? = + (keystoreProperties.getProperty(key) ?: System.getenv(env))?.takeUnless { it.isBlank() } + +val releaseStoreFile = signing("storeFile", "PUSHTHAT_KEYSTORE_PATH") +val releaseStorePassword = signing("storePassword", "PUSHTHAT_KEYSTORE_PASSWORD") +val releaseKeyAlias = signing("keyAlias", "PUSHTHAT_KEY_ALIAS") +val releaseKeyPassword = signing("keyPassword", "PUSHTHAT_KEY_PASSWORD") +val hasReleaseSigning = listOf(releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword) + .all { it != null } android { namespace = "com.makerocketgonow.pushthat" @@ -24,7 +37,13 @@ android { applicationId = "com.makerocketgonow.pushthat" minSdk = 24 targetSdk = 36 - versionCode = 8 + /* + * Every bundle Play accepts needs a version code it has not seen, so CI passes one + * that only rises (the workflow says how it is derived). A local build keeps 8, + * the code this file carried before CI took over, which Play will not take + * again: bundles for Play come from CI now. + */ + versionCode = System.getenv("PUSHTHAT_VERSION_CODE")?.toIntOrNull() ?: 8 versionName = "1.1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -35,10 +54,10 @@ android { create("release") { // Relative paths resolve against the repo root, absolute ones are // used as given. - storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) - storePassword = keystoreProperties.getProperty("storePassword") - keyAlias = keystoreProperties.getProperty("keyAlias") - keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = rootProject.file(releaseStoreFile!!) + storePassword = releaseStorePassword + keyAlias = releaseKeyAlias + keyPassword = releaseKeyPassword } } } From 71d5e19a7e438cadbc59d7c9835ad6fbbb875a64 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 09:57:07 -0500 Subject: [PATCH 02/14] Build the Android bundle in CI and upload it to Play's closed testing track Releases were built and uploaded by hand. Unbent already ships its Android app from CI this way, and PushThat now does the same. The new `android.yml` workflow runs when `apps/android/` changes. On `main`, or when started from the Actions tab, it signs the bundle and uploads it to the `alpha` track as a draft, so a person still promotes it to testers. Pull requests and runs without the secrets build unsigned and publish nothing. The version code is the run number plus 100, so the first run lands above the codes Play already has. The docs list the five secrets it reads and the allow-list entry the upload action needs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 138 ++++++++++++++++++++++++++++++++++ AGENTS.md | 7 +- docs/android.md | 30 ++++++++ docs/deploying.md | 5 +- 4 files changed, 175 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/android.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..1223cfa --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,138 @@ +name: Android + +# Builds the release bundle and, on `main`, uploads it to Play's closed testing track. The +# unit tests run in ci.yml on every pull request; this workflow is only about the bundle, +# and is its own workflow so that a push that changes nothing in the app (a copy change on +# the site, say) does not upload a build. See "Shipping to Play" in docs/android.md for the +# secrets it reads and the one-off setup in Play Console. +on: + push: + branches: [main] + paths: + - 'apps/android/**' + - '.github/workflows/android.yml' + pull_request: + paths: + - 'apps/android/**' + - '.github/workflows/android.yml' + # Cutting a release build on demand, without waiting for a push. + workflow_dispatch: + +# A second push to a pull request makes the first one's build pointless, so don't pay to +# finish it. Only pull requests are cancelled: a `main` build and a `workflow_dispatch` +# build both use up a version code, and neither should be thrown away because something +# else started. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + working-directory: apps/android + +jobs: + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Every bundle Play accepts needs a version code it has not seen. The run number only + # ever rises, so nothing is remembered or bumped by hand, but it counts this workflow's + # runs and so starts at 1, below the codes Play already has from the app's releases by + # hand. The offset puts the first run above them. Never lower it: a code Play has seen + # is refused for good. Worked out in the shell because expressions have no arithmetic. + - name: Pick the version code + run: echo "PUSHTHAT_VERSION_CODE=$((GITHUB_RUN_NUMBER + 100))" >> "$GITHUB_ENV" + # 25 because gradle/gradle-daemon-jvm.properties asks for it; the app itself + # compiles to Java 17. + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + with: + distribution: temurin + java-version: 25 + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + + # Run here as well as in ci.yml, so a bundle that reaches Play is one whose own + # commit passed, not one that merely shares a branch with a commit that did. + - run: ./gradlew test + + # `secrets` cannot be read in an `if:`, and a step that pulls in an action cannot carry + # the `env:` a shell step uses to smuggle one in, so presence is decided once, here, + # and the steps below read the output. Both secrets, not either: the service account + # says where to publish, and the keystore is what makes a bundle Play will accept. + # Checking only the service account would turn a missing keystore into a failed + # upload, which reads as a broken publish rather than a build nobody asked to ship. + - name: Can this run publish? + id: publishable + env: + EVENT: ${{ github.event_name }} + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + PLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + run: | + if [ "$EVENT" != pull_request ] && [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "Pull request, or no Play service account or keystore: building unsigned, not publishing." >> "$GITHUB_STEP_SUMMARY" + fi + + # A pull request, or a fork, gets an unsigned bundle: that still proves the release + # path compiles, which is what a change to the build file needs to know. + - name: Build the release bundle + env: + READY: ${{ steps.publishable.outputs.ready }} + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + PUSHTHAT_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + PUSHTHAT_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + PUSHTHAT_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + if [ "$READY" = true ]; then + echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" + export PUSHTHAT_KEYSTORE_PATH="$RUNNER_TEMP/release.jks" + fi + ./gradlew bundleRelease assembleRelease + rm -f "$RUNNER_TEMP/release.jks" + + # Both formats, because they are for different places. Play takes only the bundle and + # re-signs it per device; the APK is what `adb install` and a sideload take. + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pushthat-android + path: | + apps/android/app/build/outputs/bundle/release/*.aab + apps/android/app/build/outputs/apk/release/*.apk + if-no-files-found: error + + # Play names a release after `versionName` alone when nothing is supplied, so every + # release would read "1.1.0" and the list would give no way to tell builds apart. This + # mirrors what Play shows beside the bundle, "142 (1.1.0)". `versionName` is read out + # of the build file rather than repeated here, because a second copy is one nobody + # updates when the first one changes. + - name: Name the release + id: release_name + if: steps.publishable.outputs.ready == 'true' + run: | + name=$(grep -m1 'versionName = ' app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/') + echo "name=${PUSHTHAT_VERSION_CODE} (${name})" >> "$GITHUB_OUTPUT" + + # `status: draft` stops short of releasing to testers: the bundle appears in the track + # and Play runs its checks, but a person still presses the button. That is the right + # default for something every push to `main` triggers. Change it to `completed` to + # have testers get the build without anyone asking. + # + # `alpha` is the API's name for the default closed testing track: "closed" is not a + # track name, and a custom closed track is referenced by the name it was given in Play + # Console. `tracks`, plural: the singular `track` still works but warns that it is + # going away. + - name: Publish to Play closed testing + if: steps.publishable.outputs.ready == 'true' + uses: r0adkll/upload-google-play@e738b9dd8f2476ea806d921b64aacd24f34515a5 # v1.1.5 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.makerocketgonow.pushthat + releaseFiles: apps/android/app/build/outputs/bundle/release/*.aab + tracks: alpha + status: draft + releaseName: ${{ steps.release_name.outputs.name }} diff --git a/AGENTS.md b/AGENTS.md index 93a4956..77bfbb2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,8 @@ functions' suites deliver signed events to the webhook over HTTP and point CI (`.github/workflows/ci.yml`) runs the functions' unit tests, the web app's lint, key vectors and builds, the Android unit tests and the encryption reference's tests on every -pull request. +pull request. `.github/workflows/android.yml` builds the Android release bundle, and on +`main` uploads it to Play as a draft ([docs/android.md](docs/android.md#shipping-to-play)). The emulator suites run only in the deploy that follows a merge, and the Xcode builds not at all, so run them yourself when a change reaches the rules, the functions or the Apple apps: by the time CI runs them, the change is on `main`. @@ -136,5 +137,5 @@ tag through the API (`gh api repos///git/ref/tags/`, then th object's commit if the type is `tag`), not by copying a SHA from a release page. Actions outside `actions/*` and verified publishers must also be on the allow-list in the -repository's Actions settings, which today names `gradle/actions/*` and -`google-github-actions/auth`. A run that uses anything else is refused before it starts. +repository's Actions settings, which today names `gradle/actions/*`, +`google-github-actions/auth` and `r0adkll/upload-google-play`. A run that uses anything else is refused before it starts. diff --git a/docs/android.md b/docs/android.md index 1ffc445..02e7091 100644 --- a/docs/android.md +++ b/docs/android.md @@ -46,6 +46,36 @@ absolute one is used as given; keeping it outside the repository is recommended) `keyAlias`. A wrong `storeFile` fails the build rather than falling back to unsigned, so a typo cannot quietly ship an unsigned bundle. +Each value can also come from the environment, which is how CI signs: +`PUSHTHAT_KEYSTORE_PATH`, `PUSHTHAT_KEYSTORE_PASSWORD`, `PUSHTHAT_KEY_ALIAS` and +`PUSHTHAT_KEY_PASSWORD`. A value in `keystore.properties` wins over its variable. + +## Shipping to Play + +A push to `main` that touches `apps/android/` builds a signed bundle and uploads it to the +closed testing track as a **draft** (`.github/workflows/android.yml`): it appears in Play +Console and passes Play's checks, but nobody receives it until a person promotes it. Making +testers get it automatically is a one-word change (`status: completed`). Run the workflow +from the Actions tab to cut a build without a push. Pull requests build the bundle +unsigned, which proves the release path compiles, and publish nothing. + +Don't build a bundle by hand for Play. A local build keeps `versionCode = 8`, which Play has +already seen; CI passes the workflow's run number plus 100, which only rises. Never lower +that offset: a code Play has seen is refused for good. + +The workflow reads five repository secrets, and builds unsigned without publishing when +either the keystore or the service account is missing: + +- `ANDROID_KEYSTORE_BASE64`: the upload keystore, `base64 -i `. +- `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`: the same values + as in `keystore.properties`. +- `PLAY_SERVICE_ACCOUNT_JSON`: the JSON key of a Google Cloud service account that has + been invited in Play Console (Users and permissions) with release permission on this + app. One account can publish several apps in the same developer account. + +The upload action, `r0adkll/upload-google-play`, has to be on the repository's Actions +allow-list, or the run is refused before it starts. + ## Conventions Every string is in `res/values/strings.xml`. The app works in light and dark mode, using diff --git a/docs/deploying.md b/docs/deploying.md index 9b910db..9cef774 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -4,8 +4,9 @@ A push to `main` deploys itself. The CI workflow (`.github/workflows/ci.yml`) ru checks, and when they pass it deploys what the push changed to the `push-that` Firebase project: the functions when `functions/` changed, and the site with the Firestore and Storage rules when `apps/web/` changed. A change to `firebase.json` or `.firebaserc` -deploys both. Nothing else ships from CI: the Chrome extension, the Android app and the -Apple apps are still released by hand. +deploys both. The Android app has its own workflow, which uploads a draft to Play's closed +testing track (see [android.md](android.md#shipping-to-play)). Nothing else ships from CI: +the Chrome extension and the Apple apps are still released by hand. ## What a deploy runs From 2ecce7d312c1b5d3871ffddb3836ab0e30f13a82 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 10:26:31 -0500 Subject: [PATCH 03/14] Upload a hand-written "What's new" with each Play build Without one, every build reached testers with Play's empty placeholder, and filling it in by hand in Play Console is the step that gets skipped. Unbent ships its notes from the repository the same way. The note lives in `apps/android/store/whatsnew/whatsnew-en-US` and the upload step passes that directory to Play. It is written for the person holding the phone rather than generated from commit subjects, which are addressed to whoever changes the code next. The first note describes end-to-end encryption and the fixes since 1.1.0. AGENTS.md now asks for the note to be updated in the same pull request as any change someone would notice, and `docs/android.md` says how it accumulates until `versionName` changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 6 ++++++ AGENTS.md | 6 ++++++ apps/android/store/whatsnew/whatsnew-en-US | 3 +++ docs/android.md | 17 +++++++++++++++++ 4 files changed, 32 insertions(+) create mode 100644 apps/android/store/whatsnew/whatsnew-en-US diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 1223cfa..bab1a70 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -136,3 +136,9 @@ jobs: tracks: alpha status: draft releaseName: ${{ steps.release_name.outputs.name }} + # Written by hand in `apps/android/store/whatsnew/`, not generated from commit + # subjects: those are addressed to whoever changes the code next, and a tester + # reading them learns nothing about what is different on their phone. A release + # whose note nobody updated ships the previous one. See "Release notes" in + # docs/android.md. + whatsNewDirectory: apps/android/store/whatsnew diff --git a/AGENTS.md b/AGENTS.md index 77bfbb2..46674a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,12 @@ are not. - The app works in light and dark mode. Use the semantic colors in `res/values/colors.xml` and `res/values-night/colors.xml`, never a literal. - New styles go in `themes.xml`. +- A change someone would notice on their phone updates the release note, + `store/whatsnew/whatsnew-en-US`, in the same pull request. CI uploads that file as the + build's "What's new", so a change without it ships under the previous note. Write it for + the person holding the phone, not as a commit subject, and keep it under Play's 500 + characters. A bug fix nobody saw, a refactor or a test changes nothing there. See + [docs/android.md](docs/android.md#release-notes). ## The iOS and macOS apps diff --git a/apps/android/store/whatsnew/whatsnew-en-US b/apps/android/store/whatsnew/whatsnew-en-US new file mode 100644 index 0000000..145c21d --- /dev/null +++ b/apps/android/store/whatsnew/whatsnew-en-US @@ -0,0 +1,3 @@ +Your pushes are now end-to-end encrypted: only devices you sign in on can read them. Signing in sets up your keys from your password and shows a recovery key. Save it, because it is the only way to read your pushes again if you forget your password. Change password and Show a new recovery key are in the menu. + +Notifications and search work on encrypted pushes. A share that cannot be confirmed is kept and retried instead of dropped, and a long press copies a file push's name. diff --git a/docs/android.md b/docs/android.md index 02e7091..5264d6b 100644 --- a/docs/android.md +++ b/docs/android.md @@ -76,6 +76,23 @@ either the keystore or the service account is missing: The upload action, `r0adkll/upload-google-play`, has to be on the repository's Actions allow-list, or the run is refused before it starts. +## Release notes + +`store/whatsnew/whatsnew-en-US` is what testers see under "What's new". The workflow passes +the directory to the upload, so editing that file is how the note changes: nothing is +entered in Play Console by hand, and a release whose note nobody updated ships the previous +one. + +Write it for someone holding the phone: what is different for them, in their words. Commit +subjects are addressed to whoever changes the code next and make poor release notes, so +the note is written by hand rather than generated from them. + +The note covers everything since `versionName` last changed. A change someone would notice +adds to it, in the same pull request; bumping `versionName` starts it over. Play's limit is +500 characters per locale, so when it is full, cut the least noticeable line rather than +squeezing. Another language is another file beside it, named `whatsnew-` +(`whatsnew-de-DE`). + ## Conventions Every string is in `res/values/strings.xml`. The app works in light and dark mode, using From 3e6780cb46d1645579132c9c1da45006b26be45b Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 10:26:32 -0500 Subject: [PATCH 04/14] Call the Android build with end-to-end encryption 1.2.0 1.1.0 is the build Play already has, from before the app encrypted pushes. Encryption, the recovery key and Change password are a feature release for whoever updates, and the version testers see should say so. Co-Authored-By: Claude Opus 5.5 --- apps/android/app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index acc1602..f97f135 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -44,7 +44,7 @@ android { * again: bundles for Play come from CI now. */ versionCode = System.getenv("PUSHTHAT_VERSION_CODE")?.toIntOrNull() ?: 8 - versionName = "1.1.0" + versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From dfc8a57275188af8d67bb0952b74dea34b63da5a Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:09:32 -0500 Subject: [PATCH 05/14] Publish to Play only from `main` The publish check excluded pull requests and nothing else, so starting the workflow from the Actions tab on a feature branch would sign that branch's unreviewed code and upload it to Play with a version code above `main`'s latest build. The docs already said publishing happens on `main`. The check now requires `refs/heads/main`, and the step summary, the trigger's comment and `docs/android.md` say that another branch only builds. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 17 +++++++++++------ docs/android.md | 5 +++-- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index bab1a70..1594964 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -15,7 +15,8 @@ on: paths: - 'apps/android/**' - '.github/workflows/android.yml' - # Cutting a release build on demand, without waiting for a push. + # Cutting a release build on demand, without waiting for a push. Started on `main` it + # publishes; on any other branch it only builds. workflow_dispatch: # A second push to a pull request makes the first one's build pointless, so don't pay to @@ -64,22 +65,26 @@ jobs: # says where to publish, and the keystore is what makes a bundle Play will accept. # Checking only the service account would turn a missing keystore into a failed # upload, which reads as a broken publish rather than a build nobody asked to ship. + # + # Only `main`, not only "not a pull request": the Actions tab can start this workflow + # on any branch, and without the ref check that would sign unreviewed code and upload + # it above `main`'s latest build. - name: Can this run publish? id: publishable env: - EVENT: ${{ github.event_name }} + REF: ${{ github.ref }} KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} PLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} run: | - if [ "$EVENT" != pull_request ] && [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then + if [ "$REF" = refs/heads/main ] && [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" - echo "Pull request, or no Play service account or keystore: building unsigned, not publishing." >> "$GITHUB_STEP_SUMMARY" + echo "Not on \`main\`, or no Play service account or keystore: building unsigned, not publishing." >> "$GITHUB_STEP_SUMMARY" fi - # A pull request, or a fork, gets an unsigned bundle: that still proves the release - # path compiles, which is what a change to the build file needs to know. + # A pull request, another branch or a fork gets an unsigned bundle: that still proves + # the release path compiles, which is what a change to the build file needs to know. - name: Build the release bundle env: READY: ${{ steps.publishable.outputs.ready }} diff --git a/docs/android.md b/docs/android.md index 5264d6b..135f32e 100644 --- a/docs/android.md +++ b/docs/android.md @@ -56,8 +56,9 @@ A push to `main` that touches `apps/android/` builds a signed bundle and uploads closed testing track as a **draft** (`.github/workflows/android.yml`): it appears in Play Console and passes Play's checks, but nobody receives it until a person promotes it. Making testers get it automatically is a one-word change (`status: completed`). Run the workflow -from the Actions tab to cut a build without a push. Pull requests build the bundle -unsigned, which proves the release path compiles, and publish nothing. +from the Actions tab on `main` to cut a build without a push. Pull requests and other +branches build the bundle unsigned, which proves the release path compiles, and publish +nothing. Don't build a bundle by hand for Play. A local build keeps `versionCode = 8`, which Play has already seen; CI passes the workflow's run number plus 100, which only rises. Never lower From 8ac89912b17d3f94dd1fda18cdee36f4b85551bf Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:09:46 -0500 Subject: [PATCH 06/14] Keep the signing secrets out of a build that does not publish The build step put the keystore and its passwords in its environment on every run, and that environment reaches Gradle. On a pull request Gradle runs the branch's own build files, so the passwords went to code nobody had reviewed yet, for a build that leaves the bundle unsigned anyway. Each secret is now blanked unless the run publishes. An author who can edit the workflow could still undo this, so it narrows what an ordinary pull request sees rather than closing every path. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 1594964..7976c6a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -85,13 +85,18 @@ jobs: # A pull request, another branch or a fork gets an unsigned bundle: that still proves # the release path compiles, which is what a change to the build file needs to know. + # + # Each secret is blanked unless this run publishes. Everything in a step's `env:` + # reaches Gradle, and on a pull request Gradle runs that branch's own build files, so + # an unconditional `env:` would hand the signing passwords to code nobody has reviewed + # yet, for a build that never uses them. - name: Build the release bundle env: READY: ${{ steps.publishable.outputs.ready }} - KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - PUSHTHAT_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - PUSHTHAT_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} - PUSHTHAT_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + KEYSTORE: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEYSTORE_BASE64 || '' }} + PUSHTHAT_KEYSTORE_PASSWORD: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEYSTORE_PASSWORD || '' }} + PUSHTHAT_KEY_ALIAS: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEY_ALIAS || '' }} + PUSHTHAT_KEY_PASSWORD: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEY_PASSWORD || '' }} run: | if [ "$READY" = true ]; then echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" From af18ffa4737e5b971e0b58b678a6ea61396f4458 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:09:58 -0500 Subject: [PATCH 07/14] Warn that renaming the Android workflow resets its version codes The version code is the workflow's run number plus an offset, and GitHub counts runs per workflow file. Renaming or moving `android.yml` starts the count at 1 again, which falls below codes Play already has, and every upload would be refused until someone raised the offset. The comment said never to lower the offset but not that a rename has the same effect. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 7976c6a..1581c3f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -44,7 +44,9 @@ jobs: # ever rises, so nothing is remembered or bumped by hand, but it counts this workflow's # runs and so starts at 1, below the codes Play already has from the app's releases by # hand. The offset puts the first run above them. Never lower it: a code Play has seen - # is refused for good. Worked out in the shell because expressions have no arithmetic. + # is refused for good. Renaming or moving this file starts the run number over at 1, + # so raise the offset past the last code Play has when you do. Worked out in the shell + # because expressions have no arithmetic. - name: Pick the version code run: echo "PUSHTHAT_VERSION_CODE=$((GITHUB_RUN_NUMBER + 100))" >> "$GITHUB_ENV" # 25 because gradle/gradle-daemon-jvm.properties asks for it; the app itself From e405be7fe4f25ca7bb7ed9daee95459cb280e031 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:09:58 -0500 Subject: [PATCH 08/14] Say what the Android workflow's concurrency actually keeps The comment said a `main` or `workflow_dispatch` build is never thrown away because something else started. A run that has started does finish, but GitHub keeps only one waiting run per group, so a queued build is dropped when a newer one arrives. That is the right outcome, since the newer build supersedes it; the comment now says so, and why runs go one at a time. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 1581c3f..0dede82 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,9 +20,10 @@ on: workflow_dispatch: # A second push to a pull request makes the first one's build pointless, so don't pay to -# finish it. Only pull requests are cancelled: a `main` build and a `workflow_dispatch` -# build both use up a version code, and neither should be thrown away because something -# else started. +# finish it. Elsewhere a run that has started always finishes, and runs go one at a time, +# so two uploads never race on Play. GitHub still keeps only the newest waiting run and +# drops one queued behind it, which is fine: the newer build supersedes it, and a skipped +# version code costs nothing. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} From a9dc1aa849b194ab2771e5c57482932ecd2b362f Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:10:17 -0500 Subject: [PATCH 09/14] Fail the Android build on a version code that is not a number `PUSHTHAT_VERSION_CODE` fell back to 8 whenever it did not parse, so a mistake in the workflow's arithmetic would build a bundle with a code Play already has. The cause would only show up later, as Play refusing the upload. A value that is set but not a number now stops the build at configuration time and names itself; unset still means a local build and keeps 8. Co-Authored-By: Claude Opus 5.5 --- apps/android/app/build.gradle.kts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index f97f135..b33696a 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -41,9 +41,13 @@ android { * Every bundle Play accepts needs a version code it has not seen, so CI passes one * that only rises (the workflow says how it is derived). A local build keeps 8, * the code this file carried before CI took over, which Play will not take - * again: bundles for Play come from CI now. + * again: bundles for Play come from CI now. A value that is set but not a number + * fails the build here, rather than quietly becoming 8 and surfacing later as Play + * refusing the upload. */ - versionCode = System.getenv("PUSHTHAT_VERSION_CODE")?.toIntOrNull() ?: 8 + versionCode = System.getenv("PUSHTHAT_VERSION_CODE")?.let { + it.toIntOrNull() ?: error("PUSHTHAT_VERSION_CODE is not a number: $it") + } ?: 8 versionName = "1.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 29b4bad644da89523f2f53e293bc26878196d394 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:10:17 -0500 Subject: [PATCH 10/14] Rewrap the Actions allow-list sentence in AGENTS.md Adding `r0adkll/upload-google-play` left the sentence on one long line where the rest of the file wraps at about 90 columns. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 46674a8..fe85aa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,4 +144,5 @@ object's commit if the type is `tag`), not by copying a SHA from a release page. Actions outside `actions/*` and verified publishers must also be on the allow-list in the repository's Actions settings, which today names `gradle/actions/*`, -`google-github-actions/auth` and `r0adkll/upload-google-play`. A run that uses anything else is refused before it starts. +`google-github-actions/auth` and `r0adkll/upload-google-play`. A run that uses anything +else is refused before it starts. From c2fd27abb8c8ecec5dd7acfae6da5ac8217fbb0d Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 11:16:48 -0500 Subject: [PATCH 11/14] Keep the Play signing secrets on a `main`-only Environment Repository secrets reach every run a branch of this repository starts, and a pull request runs its own copy of the workflow, so a branch could edit `android.yml` to read the keystore and the Play service account key before anyone reviewed it. The checks added earlier lived in that same file, so a branch could also remove them. The workflow is now two jobs. `build` runs everywhere and holds no secrets: the tests and an unsigned bundle. `release` runs on `main`, declares the `play` Environment, and signs and uploads. The Environment accepts only `main`, and GitHub enforces that before the job starts, whatever the workflow file says. `docs/android.md` says where the secrets live and how to set them, and AGENTS.md makes it the rule for any secret that signs or publishes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 95 +++++++++++++++++++---------------- AGENTS.md | 6 +++ docs/android.md | 19 +++++-- 3 files changed, 74 insertions(+), 46 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0dede82..d760a85 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -4,7 +4,7 @@ name: Android # unit tests run in ci.yml on every pull request; this workflow is only about the bundle, # and is its own workflow so that a push that changes nothing in the app (a copy change on # the site, say) does not upload a build. See "Shipping to Play" in docs/android.md for the -# secrets it reads and the one-off setup in Play Console. +# secrets it reads and the one-off setup in Play Console and GitHub. on: push: branches: [main] @@ -36,20 +36,13 @@ defaults: working-directory: apps/android jobs: - bundle: + # Every run, pull requests included. It holds no secrets at all, so it is safe to run the + # build files of a branch nobody has reviewed yet: the bundle comes out unsigned, which + # still proves the release path compiles, and is not kept. + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Every bundle Play accepts needs a version code it has not seen. The run number only - # ever rises, so nothing is remembered or bumped by hand, but it counts this workflow's - # runs and so starts at 1, below the codes Play already has from the app's releases by - # hand. The offset puts the first run above them. Never lower it: a code Play has seen - # is refused for good. Renaming or moving this file starts the run number over at 1, - # so raise the offset past the last code Play has when you do. Worked out in the shell - # because expressions have no arithmetic. - - name: Pick the version code - run: echo "PUSHTHAT_VERSION_CODE=$((GITHUB_RUN_NUMBER + 100))" >> "$GITHUB_ENV" # 25 because gradle/gradle-daemon-jvm.properties asks for it; the app itself # compiles to Java 17. - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 @@ -57,10 +50,34 @@ jobs: distribution: temurin java-version: 25 - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 - # Run here as well as in ci.yml, so a bundle that reaches Play is one whose own # commit passed, not one that merely shares a branch with a commit that did. - run: ./gradlew test + - run: ./gradlew bundleRelease assembleRelease + + # Signs and uploads, on `main` only. The signing and Play secrets live on the `play` + # Environment, not on the repository, and the Environment accepts only `main` (Settings, + # Environments). That check is GitHub's, made before the job starts, so it holds even + # against a pull request that edits this file: a run from any other ref is refused the + # Environment and never sees a secret. The `if:` below only skips the job cleanly instead + # of letting it fail on that refusal. + release: + needs: build + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: play + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Every bundle Play accepts needs a version code it has not seen. The run number only + # ever rises, so nothing is remembered or bumped by hand, but it counts this workflow's + # runs and so starts at 1, below the codes Play already has from the app's releases by + # hand. The offset puts the first run above them. Never lower it: a code Play has seen + # is refused for good. Renaming or moving this file starts the run number over at 1, + # so raise the offset past the last code Play has when you do. Worked out in the shell + # because expressions have no arithmetic. + - name: Pick the version code + run: echo "PUSHTHAT_VERSION_CODE=$((GITHUB_RUN_NUMBER + 100))" >> "$GITHUB_ENV" # `secrets` cannot be read in an `if:`, and a step that pulls in an action cannot carry # the `env:` a shell step uses to smuggle one in, so presence is decided once, here, @@ -68,49 +85,43 @@ jobs: # says where to publish, and the keystore is what makes a bundle Play will accept. # Checking only the service account would turn a missing keystore into a failed # upload, which reads as a broken publish rather than a build nobody asked to ship. - # - # Only `main`, not only "not a pull request": the Actions tab can start this workflow - # on any branch, and without the ref check that would sign unreviewed code and upload - # it above `main`'s latest build. - name: Can this run publish? id: publishable env: - REF: ${{ github.ref }} KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} PLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} run: | - if [ "$REF" = refs/heads/main ] && [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then + if [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then echo "ready=true" >> "$GITHUB_OUTPUT" else echo "ready=false" >> "$GITHUB_OUTPUT" - echo "Not on \`main\`, or no Play service account or keystore: building unsigned, not publishing." >> "$GITHUB_STEP_SUMMARY" + echo "No Play service account or keystore on the \`play\` Environment: not publishing." >> "$GITHUB_STEP_SUMMARY" fi - # A pull request, another branch or a fork gets an unsigned bundle: that still proves - # the release path compiles, which is what a change to the build file needs to know. - # - # Each secret is blanked unless this run publishes. Everything in a step's `env:` - # reaches Gradle, and on a pull request Gradle runs that branch's own build files, so - # an unconditional `env:` would hand the signing passwords to code nobody has reviewed - # yet, for a build that never uses them. - - name: Build the release bundle + - uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 + if: steps.publishable.outputs.ready == 'true' + with: + distribution: temurin + java-version: 25 + - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0 + if: steps.publishable.outputs.ready == 'true' + + - name: Build the signed bundle + if: steps.publishable.outputs.ready == 'true' env: - READY: ${{ steps.publishable.outputs.ready }} - KEYSTORE: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEYSTORE_BASE64 || '' }} - PUSHTHAT_KEYSTORE_PASSWORD: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEYSTORE_PASSWORD || '' }} - PUSHTHAT_KEY_ALIAS: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEY_ALIAS || '' }} - PUSHTHAT_KEY_PASSWORD: ${{ steps.publishable.outputs.ready == 'true' && secrets.ANDROID_KEY_PASSWORD || '' }} + KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + PUSHTHAT_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + PUSHTHAT_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + PUSHTHAT_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | - if [ "$READY" = true ]; then - echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" - export PUSHTHAT_KEYSTORE_PATH="$RUNNER_TEMP/release.jks" - fi - ./gradlew bundleRelease assembleRelease + echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" + PUSHTHAT_KEYSTORE_PATH="$RUNNER_TEMP/release.jks" ./gradlew bundleRelease assembleRelease rm -f "$RUNNER_TEMP/release.jks" # Both formats, because they are for different places. Play takes only the bundle and # re-signs it per device; the APK is what `adb install` and a sideload take. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: steps.publishable.outputs.ready == 'true' with: name: pushthat-android path: | @@ -119,10 +130,10 @@ jobs: if-no-files-found: error # Play names a release after `versionName` alone when nothing is supplied, so every - # release would read "1.1.0" and the list would give no way to tell builds apart. This - # mirrors what Play shows beside the bundle, "142 (1.1.0)". `versionName` is read out - # of the build file rather than repeated here, because a second copy is one nobody - # updates when the first one changes. + # release of one version would read the same and the list would give no way to tell + # builds apart. This mirrors what Play shows beside the bundle, "142 (1.2.0)". + # `versionName` is read out of the build file rather than repeated here, because a + # second copy is one nobody updates when the first one changes. - name: Name the release id: release_name if: steps.publishable.outputs.ready == 'true' diff --git a/AGENTS.md b/AGENTS.md index fe85aa1..ff36495 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,3 +146,9 @@ Actions outside `actions/*` and verified publishers must also be on the allow-li repository's Actions settings, which today names `gradle/actions/*`, `google-github-actions/auth` and `r0adkll/upload-google-play`. A run that uses anything else is refused before it starts. + +A secret that signs or publishes lives on an Environment that accepts only `main`, never +on the repository: a pull request runs its own copy of the workflow, so a repository +secret is one any branch can read. The Android release job uses the `play` Environment +([docs/android.md](docs/android.md#shipping-to-play)). A job that needs such a secret +declares the Environment, and a job that runs on pull requests holds none. diff --git a/docs/android.md b/docs/android.md index 135f32e..c91ee9f 100644 --- a/docs/android.md +++ b/docs/android.md @@ -57,15 +57,15 @@ closed testing track as a **draft** (`.github/workflows/android.yml`): it appear Console and passes Play's checks, but nobody receives it until a person promotes it. Making testers get it automatically is a one-word change (`status: completed`). Run the workflow from the Actions tab on `main` to cut a build without a push. Pull requests and other -branches build the bundle unsigned, which proves the release path compiles, and publish -nothing. +branches run only the `build` job: the tests and an unsigned bundle, which proves the +release path compiles, with no secrets and nothing published. Don't build a bundle by hand for Play. A local build keeps `versionCode = 8`, which Play has already seen; CI passes the workflow's run number plus 100, which only rises. Never lower that offset: a code Play has seen is refused for good. -The workflow reads five repository secrets, and builds unsigned without publishing when -either the keystore or the service account is missing: +The `release` job reads five secrets from the repository's `play` Environment, and skips +publishing when either the keystore or the service account is missing: - `ANDROID_KEYSTORE_BASE64`: the upload keystore, `base64 -i `. - `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`: the same values @@ -77,6 +77,17 @@ either the keystore or the service account is missing: The upload action, `r0adkll/upload-google-play`, has to be on the repository's Actions allow-list, or the run is refused before it starts. +The secrets are on the Environment, not the repository, because the Environment accepts +only `main` (Settings, Environments, `play`, Deployment branches) and GitHub checks that +before the job starts. Repository secrets reach every run a branch of this repository +starts, and a pull request runs its own copy of the workflow, so a branch could edit the +workflow to read them before anyone reviewed it. A secret set without `--env play` lands +back on the repository and reopens that, so set them as: + +```bash +gh secret set ANDROID_KEY_PASSWORD --env play --repo MakeRocketGoNow/pushthat +``` + ## Release notes `store/whatsnew/whatsnew-en-US` is what testers see under "What's new". The workflow passes From fe9108261f48b4d384966563650c57d6b0bfda31 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 12:27:05 -0500 Subject: [PATCH 12/14] Keep the signed Android builds as artifacts for three days, not 90 The repository is public, so anyone signed in to GitHub can download a run's artifacts. At the default retention every closed-testing build, the bundle and the upload-key-signed APK, stayed open to them for 90 days, which undercut keeping the build on a closed track. Three days is enough to sideload a build for a quick check, and Play keeps its own copy of the bundle. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index d760a85..ef40266 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -120,6 +120,11 @@ jobs: # Both formats, because they are for different places. Play takes only the bundle and # re-signs it per device; the APK is what `adb install` and a sideload take. + # + # The repository is public, so anyone signed in to GitHub can download a run's + # artifacts. The default 90 days would leave every closed-testing build open to them; + # three is long enough to sideload a build for a quick check, and Play keeps its own + # copy of the bundle for anything later. - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: steps.publishable.outputs.ready == 'true' with: @@ -128,6 +133,7 @@ jobs: apps/android/app/build/outputs/bundle/release/*.aab apps/android/app/build/outputs/apk/release/*.apk if-no-files-found: error + retention-days: 3 # Play names a release after `versionName` alone when nothing is supplied, so every # release of one version would read the same and the list would give no way to tell From cb1e484118b8eae8c6781d7d9afe1a924d7a6f62 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 12:27:15 -0500 Subject: [PATCH 13/14] Stop calling a local signed bundle ready to upload `docs/android.md` and `keystore.properties.example` still said a local `bundleRelease` produced an AAB ready for Play. Since CI took over the version code, a local build keeps `versionCode = 8`, which Play has already seen and refuses, so both now describe the local signed build as being for trying a release build and point to CI for Play. Co-Authored-By: Claude Opus 5.5 --- apps/android/keystore.properties.example | 4 +++- docs/android.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/android/keystore.properties.example b/apps/android/keystore.properties.example index 6a27f73..fdbbd5f 100644 --- a/apps/android/keystore.properties.example +++ b/apps/android/keystore.properties.example @@ -6,7 +6,9 @@ # keystore itself. Without it the release build is left unsigned, which is what # a fresh clone gets. # -# With it, ./gradlew bundleRelease produces a signed AAB ready to upload. +# With it, ./gradlew bundleRelease produces a signed AAB for trying a release +# build locally. Bundles for Play come from CI: a local one keeps a version code +# Play has already seen. See "Shipping to Play" in docs/android.md. # Path to the upload keystore. Relative paths resolve against the repo root; # absolute paths are used as given. Keeping the keystore outside the repo is diff --git a/docs/android.md b/docs/android.md index c91ee9f..e047495 100644 --- a/docs/android.md +++ b/docs/android.md @@ -34,7 +34,8 @@ first if it is not running. `scripts/build-and-run.sh` assembles the APK first. Release signing credentials live in `keystore.properties`, which is gitignored, as is any keystore. Without it the release build is left unsigned, which is what a fresh clone gets. -With it, `./gradlew bundleRelease` produces a signed AAB ready to upload. +With it, `./gradlew bundleRelease` produces a signed AAB, for trying a release build +locally; bundles for Play come from CI (see [Shipping to Play](#shipping-to-play)). ```bash cp keystore.properties.example keystore.properties From a03e42645b1cea2937802cce390a7dbd4651ea06 Mon Sep 17 00:00:00 2001 From: C Deutsch Date: Tue, 27 Oct 2026 12:27:24 -0500 Subject: [PATCH 14/14] Remove the decoded keystore even when the signed build fails The step deleted the keystore with a closing `rm`, but Actions runs it under `bash -e`, so a failed Gradle build exited first and left the keystore on disk. Hosted runners are discarded after the job, so nothing leaked, but the `rm` read as a guarantee it did not give. An `EXIT` trap makes it one. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ef40266..b3d0bab 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -113,10 +113,12 @@ jobs: PUSHTHAT_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} PUSHTHAT_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} PUSHTHAT_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + # A trap rather than a closing `rm`: the step runs under `bash -e`, so a failed build + # would exit before reaching it and leave the keystore on disk. run: | + trap 'rm -f "$RUNNER_TEMP/release.jks"' EXIT echo "$KEYSTORE" | base64 -d > "$RUNNER_TEMP/release.jks" PUSHTHAT_KEYSTORE_PATH="$RUNNER_TEMP/release.jks" ./gradlew bundleRelease assembleRelease - rm -f "$RUNNER_TEMP/release.jks" # Both formats, because they are for different places. Play takes only the bundle and # re-signs it per device; the APK is what `adb install` and a sideload take.