From 8a05323905e6c3f19d80defedeae548f652d636b Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Tue, 21 Jul 2026 13:52:15 -0400 Subject: [PATCH] docs: add reusable Heluca Google Play publishing guide Playbook for taking any Heluca app from repo to Google Play, learned from shipping Dade to internal testing: one-time publisher account + upload keystore setup, per-app checklist (signing via -Pandroid.injected.signing.*, AAB, versionCode encoding, native symbols), internal-testing flow, gotchas, and production promotion. Bakes in Heluca conventions and documents where Dade deliberately diverges (custom signingConfig, UPLOAD_* secret names, :latest pin) so the next app can follow the template instead. Link it from the README. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 + docs/google-play-publishing.md | 240 +++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 docs/google-play-publishing.md diff --git a/README.md b/README.md index 316f3a8..a740202 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ tests are **not** part of this toolchain — by design, CI builds are promotions of code already tested in Dev (on Apple silicon, where the emulator runs natively). +**Publishing to Google Play?** See [`docs/google-play-publishing.md`](docs/google-play-publishing.md) +— the reusable Heluca playbook (publisher setup, upload keystore, per-app +checklist, internal testing, promotion to production), learned from shipping Dade. + **Why amd64:** Google publishes the Linux Android build-tools (`aapt2`, `d8`, …) for **x86_64 only** — there is no arm64 Linux build-tools package. Running the toolchain on an aarch64 runner fails at resource packaging diff --git a/docs/google-play-publishing.md b/docs/google-play-publishing.md new file mode 100644 index 0000000..05e8a50 --- /dev/null +++ b/docs/google-play-publishing.md @@ -0,0 +1,240 @@ +# Publishing a Heluca Android app to Google Play + +The reusable playbook for taking a Heluca app from repo to Google Play, learned +from shipping **Dade** (`r/dade`) to internal testing. It covers the whole +lifecycle: the one-time publisher setup, the per-app checklist, and promotion to +production — with Heluca's conventions (this `r/android` toolchain, Gitea CI, +Play App Signing) baked in. + +Authority order for anything ambiguous: **live app config > this guide > memory**. +When an app diverges from the convention here, that's allowed — but note it in the +app's own README so the next person knows it's deliberate. Dade's divergences are +called out throughout (see *Where Dade diverges*). + +--- + +## 0. Concepts you need once + +- **Two keys, one you hold.** Under **Play App Signing** (use it — it's the + default and lets Google reset a lost upload key), you generate an **upload key** + and sign your bundle with it; Google re-signs with the **app signing key** it + holds. You never see the app key. +- **The upload key is publisher-wide, not per-app.** One keystore signs every + Heluca app — Play keys an app by `applicationId`, not by signing key. Generate it + once (below), reuse it forever. Don't mint a new one per app. +- **Play wants an `.aab`, not an APK.** App Bundles only. That means + `bundleRelease` (or a flavored `bundleRelease`), never `assembleRelease`, + for anything going to Play. (`assembleRelease` APKs remain useful for direct + `adb install` sideloading — Dade keeps a separate workflow for exactly that.) +- **`versionCode` is a bare integer Play compares; `versionName` is the human + label.** Every upload needs a strictly-greater `versionCode`. Heluca convention: + **encode the semver** as `MAJOR*10000 + MINOR*100 + PATCH` so the two track each + other (`0.0.1` → `1`, `1.2.3` → `10203`). Bump both together per release. +- **One listing per `applicationId`.** If an app ships per-environment flavors with + distinct ids (Dade: `ca.helu.daedalus` prod, `ca.helu.daedalus.dev` dev), each is + a **separate Play app** with its own listing, track, and opt-in link. + +--- + +## 1. Publisher account — one-time for all of Heluca + +Done once; every future app reuses it. + +1. **Register** at https://play.google.com/console with a long-lived Google + account (not a throwaway). **$25 one-time** fee. +2. **Account type:** Personal is adequate — internal testing isn't gated either + way. Organization needs a D-U-N-S number and unlocks skipping the + new-personal-account production testing gate; only worth it if you'll push to + production broadly. +3. **Identity verification** (2026 rules): government ID, possibly a selfie and an + address document. Can take a few hours to a couple of days. +4. **Device verification** (personal accounts): install the **Play Console** app + on a real Android phone and confirm it before you can publish. + +## 2. Generate the upload keystore — one-time for all of Heluca + +Lives in `~/.android` (alongside the Android debug keystore) — out of your home +root, easy to include in backups. **Back it up and store the password in your +vault.** JDK 17+ `keytool` prompts for a single password and reuses it for both +the store and the key, so the two password secrets later hold the same value. + +```bash +keytool -genkeypair -v -keystore ~/.android/heluca-upload.jks -alias heluca-upload \ + -keyalg RSA -keysize 4096 -validity 10000 \ + -dname "CN=Heluca, O=helu.ca, C=CA" +``` + +`-dname` is cosmetic under Play App Signing (Google re-signs), so `CN=Heluca` is +fine and consistent across apps. + +Encode it once for CI secrets: + +```bash +base64 -i ~/.android/heluca-upload.jks | pbcopy # macOS +# base64 -w0 ~/.android/heluca-upload.jks # Linux (one line, no wrap) +``` + +--- + +## 3. Per-app checklist — repeat for each new app + +Assumes the account (§1) and upload keystore (§2) already exist. + +### 3a. Gradle — signing + AAB + versionCode + +The **Heluca convention** is the `r/android` template's approach: **inject signing +at job time via `-Pandroid.injected.signing.*`, no `signingConfig` in +`build.gradle.kts` at all.** This keeps the build file clean and the keystore +purely a CI concern. See `templates/build.yml` in this repo. + +Set the versionCode encoding in `defaultConfig`: + +```kotlin +defaultConfig { + // MAJOR*10000 + MINOR*100 + PATCH — versionName 0.0.1 -> versionCode 1 + versionCode = 1 + versionName = "0.0.1" +} +``` + +Nothing else is required for signing if you use `-Pandroid.injected.signing.*`. + +### 3b. Native debug symbols (only if the app bundles `.so` libraries) + +If the app ships native libraries (JNI, ONNX, a VAD lib, etc.), Play warns that +the `.aab` has no native debug symbols and native crashes show raw addresses. Fix +it in the release build type: + +```kotlin +buildTypes { + release { + ndk { debugSymbolLevel = "SYMBOL_TABLE" } // function names; FULL adds line numbers + } +} +``` + +AGP embeds the symbols in the `.aab`; Play consumes them on upload. Use +`SYMBOL_TABLE` for prebuilt deps you don't build from source; `FULL` only if you +need line numbers. Pure-Kotlin/Java apps can skip this. + +### 3c. Minification & the mapping file + +If `isMinifyEnabled = true`, upload the R8 mapping file so Play deobfuscates +crash traces (AGP attaches it to the bundle automatically). If minification is +**off**, Play still prints a generic "no deobfuscation file" warning — **ignore +it**, there's nothing to upload. + +### 3d. CI secrets (per app repo, or org-level) + +Following the `r/android` convention, add these Gitea Action Secrets to the app +repo (**Settings → Actions → Secrets**). Org-level secrets avoid re-adding them +per app. + +| Secret | Value | +|--------|-------| +| `KEYSTORE_BASE64` | `base64 -w0 ~/.android/heluca-upload.jks` (the whole `.jks` as text) | +| `KEYSTORE_PASSWORD` | the keystore password | +| `KEY_ALIAS` | `heluca-upload` | +| `KEY_PASSWORD` | same as the keystore password (JDK 17 unifies them) | +| `PACKAGE_TOKEN` | PAT with `read:package` (to pull the toolchain image) | + +### 3e. CI workflow + +Drop `templates/build.yml` from this repo into the app at +`.gitea/workflows/build.yml`. It runs inside the pinned toolchain image, decodes +the keystore, and builds the signed artifact. For a Play app, run it with the +`bundleRelease` task (the template exposes a `workflow_dispatch` choice; flavored +apps need `bundleRelease` — adjust the task/matrix accordingly). + +**Pin the toolchain to an immutable tag** (`git.helu.ca/r/android:2026.06`), never +`:latest`, for anything whose output you'll ship. See this repo's README → +*Tagging model*. + +### 3f. Create the Play app(s) & upload + +Per `applicationId` (one app, or one per flavor): + +1. Play Console → **Create app** → name, language, app/game, free/paid, accept + declarations. +2. Complete the **App content** declarations (privacy policy URL, data safety, ads, + content rating, target audience). Internal testing tolerates a minimal honest + pass. +3. **Testing → Internal testing → Create new release** → upload the `.aab` + (download the CI artifact; it's zipped by Gitea's transport — unzip to get the + raw `app-*-release.aab`). Enable **Play App Signing** when prompted. +4. Add release notes ("What's new," ≤500 chars — describe the app for a first-time + tester, not the CI plumbing). **Save → Review release → Start rollout.** + +### 3g. Add testers & install + +1. Internal testing → **Testers** tab → create an email list → add the Google + account(s) on your phones. +2. **Copy link** (the opt-in URL) → open it **on the phone**, signed into that same + account → accept → the Play listing's **Install** appears. +3. Repeat the opt-in per app (each `applicationId` has its own link). Flavored apps + install side by side. + +--- + +## 4. Gotchas (all learned the hard way on Dade) + +- **"Item isn't available" right after opting in is normal propagation lag** — + 10–30 min typical, up to a couple of hours on a brand-new app's first release, + even when the console says "Available to internal testers." Not a + misconfiguration. Confirm the release status, confirm the **exact tester account** + is active in the phone's Play Store, then wait. +- **"Not reviewed" / "(unreviewed)" temporary app name does NOT block internal + testing** — that track is review-exempt. It clears on its own. +- **No install notification.** Google doesn't ping you that a testing app/build + exists. First install is always via the opt-in link. Updates auto-arrive (if + auto-update is on) or via Play Store → Manage apps & device → Update — no reliable + per-app notification for testing tracks. +- **Each upload needs a higher `versionCode`** or Play rejects the duplicate. The + encoding in §0 makes this mechanical. +- **Same Google account** on the device as in the tester list, or the opt-in link + 404s / "not available." +- **The AAB is already a zip; Gitea's artifact download re-zips it** — the outer + `.zip` is ~the same size because you can't re-compress compressed data. Unzip once + and upload the inner `.aab`. Harmless. + +--- + +## 5. Promoting to production + +Internal testing (≤100 testers, no review gate) is where Heluca apps live for +personal use. To go public: + +1. **Closed/open testing first if required.** New *personal* accounts must run a + closed test with **≥12 testers for ≥14 days** before production unlocks. + Organization accounts skip this. Internal testing does **not** count toward it. +2. **Full store listing + all App content declarations** must be complete (they're + optional-ish for internal, mandatory for production). +3. **Production track → Create release** → promote the same `.aab` (or a newer one) + → it goes through Google **review** (hours to days) before rollout. +4. Consider a **staged rollout** (start at a small % of users). + +For a personal, off-public-store app, you may never leave internal testing — that's +a valid end state, not a stepping stone. + +--- + +## Where Dade diverges from this convention + +Dade shipped before this guide was written and differs in a few deliberate ways. +Documented here so the next app can decide to follow the template (recommended) or +knowingly match Dade. + +| Aspect | Convention (this guide / `templates/build.yml`) | Dade | +|--------|--------------------------------------------------|------| +| Signing injection | `-Pandroid.injected.signing.*` at job time, no `signingConfig` in Gradle | Custom `signingConfig` reading `DADE_KEYSTORE*` env vars | +| Secret names | `KEYSTORE_BASE64` / `KEYSTORE_PASSWORD` / `KEY_ALIAS` / `KEY_PASSWORD` | `UPLOAD_KEYSTORE_BASE64` / `UPLOAD_KEYSTORE_PASSWORD` / `UPLOAD_KEY_PASSWORD` | +| Toolchain pin | Immutable `:2026.06` | Moving `:latest` (README flags this to pin later) | +| Workflow | One `templates/build.yml`, task selectable | Two: `build-apks.yml` (unsigned APKs, sideload) + `build-bundles.yml` (signed AABs, Play) | + +Dade's split (separate unsigned-APK and signed-AAB workflows) is worth keeping for +an app you also sideload during dev. A **new app that only targets Play** should +follow the single-template convention and standard secret names — less to maintain, +and org-level secrets work across repos when the names match. + +**New app? Prefer the convention in §3.** Reach for Dade's pattern only when you +have Dade's reason (per-env flavors + a parallel sideload path).