diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..b3d0bab --- /dev/null +++ b/.github/workflows/android.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 93a4956..ff36495 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 @@ -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`. @@ -136,5 +143,12 @@ 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. + +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/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 409a373..b33696a 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,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" } @@ -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 } } } 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/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 1ffc445..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 @@ -46,6 +47,65 @@ 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 on `main` to cut a build without a push. Pull requests and other +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 `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 + 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. + +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 +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 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