Skip to content

Build the Android bundle in CI and upload it to Play's closed testing track - #12

Merged
cdeutsch merged 14 commits into
mainfrom
claude/pushthat-play-store-ci-4e03a6
Oct 27, 2026
Merged

cdeutsch merged 14 commits into
mainfrom
claude/pushthat-play-store-ci-4e03a6

Conversation

@cdeutsch

@cdeutsch cdeutsch commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Releases of the Android app were built and uploaded by hand. Unbent already ships its Android app from CI this way; this brings the same flow to PushThat.

  • A new .github/workflows/android.yml runs when apps/android/ changes. On main, or when started from the Actions tab, it signs the bundle and uploads it to the alpha (closed testing) 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 workflow's run number plus 100. A new workflow counts from 1, and Play already has codes up to 8.
  • app/build.gradle.kts reads each signing value from keystore.properties or, failing that, a PUSHTHAT_* environment variable, and versionCode from PUSHTHAT_VERSION_CODE. Environment variables rather than CI writing the file, since Properties reads a backslash or colon in a password as syntax. Local builds are unchanged.
  • Each upload carries a hand-written "What's new" from apps/android/store/whatsnew/whatsnew-en-US, as Unbent's does. The first note describes end-to-end encryption and the fixes since 1.1.0, and versionName moves to 1.2.0 to match. AGENTS.md asks for the note to be updated in the same pull request as any change someone would notice.
  • docs/android.md gains a "Shipping to Play" section listing the five secrets; docs/deploying.md and AGENTS.md point to it, and the allow-list line names the new action.

The repository side is already set up: r0adkll/upload-google-play@* is on the Actions allow-list, the five secrets are set, and the Play service account can release PushThat to testing tracks.

Verified locally: ./gradlew test passes, bundleRelease builds unsigned, and builds signed from the environment variables (with a password containing \, : and =) carrying the version code passed in. The workflow itself has not run yet: this PR's run exercises the unsigned path, and the first signed upload happens after merge.

🤖 Generated with Claude Code

cdeutsch and others added 2 commits October 27, 2026 09:57
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
@github-actions github-actions Bot added the android The Android app (apps/android) label Oct 27, 2026
cdeutsch and others added 2 commits October 27, 2026 10:26
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
cdeutsch and others added 10 commits October 27, 2026 11:09
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@cdeutsch
cdeutsch enabled auto-merge October 27, 2026 17:28
@cdeutsch
cdeutsch merged commit 9590ed5 into main Oct 27, 2026
8 checks passed
@cdeutsch
cdeutsch deleted the claude/pushthat-play-store-ci-4e03a6 branch October 27, 2026 17:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android The Android app (apps/android)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant