Compare commits

...

1 Commits

Author SHA1 Message Date
8a05323905 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 <noreply@anthropic.com>
2026-07-21 13:52:15 -04:00
2 changed files with 244 additions and 0 deletions

View File

@@ -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 of code already tested in Dev (on Apple silicon, where the emulator runs
natively). 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`, **Why amd64:** Google publishes the Linux Android build-tools (`aapt2`, `d8`,
…) for **x86_64 only** — there is no arm64 Linux build-tools package. Running …) for **x86_64 only** — there is no arm64 Linux build-tools package. Running
the toolchain on an aarch64 runner fails at resource packaging the toolchain on an aarch64 runner fails at resource packaging

View File

@@ -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 `bundle<Flavor>Release`), 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 `bundle<Flavor>Release` — 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** —
1030 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).