Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
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 and GitHub.
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. 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
# 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' }}

permissions:
contents: read

defaults:
run:
working-directory: apps/android

jobs:
# 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
# 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
- 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,
# 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:
KEYSTORE: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
PLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -n "$KEYSTORE" ] && [ -n "$PLAY_SERVICE_ACCOUNT" ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
echo "No Play service account or keystore on the \`play\` Environment: not publishing." >> "$GITHUB_STEP_SUMMARY"
fi

- 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:
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 }}
# 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

# 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:
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
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
# 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'
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 }}
# 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
20 changes: 17 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -113,7 +119,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`.
Expand All @@ -136,5 +143,12 @@ tag through the API (`gh api repos/<owner>/<action>/git/ref/tags/<tag>`, 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.

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.
39 changes: 31 additions & 8 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -24,8 +37,18 @@ android {
applicationId = "com.makerocketgonow.pushthat"
minSdk = 24
targetSdk = 36
versionCode = 8
versionName = "1.1.0"
/*
* 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. 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")?.let {
it.toIntOrNull() ?: error("PUSHTHAT_VERSION_CODE is not a number: $it")
} ?: 8
versionName = "1.2.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand All @@ -35,10 +58,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
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion apps/android/keystore.properties.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/android/store/whatsnew/whatsnew-en-US
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading