Files
android/docs/google-play-publishing.md
Robert Helewka 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

11 KiB
Raw Permalink Blame History

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.11, 1.2.310203). 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.

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:

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:

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:

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).