Skip to content

Mobile store release runbook (TestFlight + Play internal testing)

How the Learning Player reaches a tester's phone on either platform, what each build type is allowed to contain, and the traps that have already cost time. Companion to MOBILE_E2E_TESTING.md, which covers testing rather than shipping.

Scope: the test channels — TestFlight and Play internal testing. Public release on either store is out of scope. Tracked under #2193.

The two build types, and why the distinction matters

Everything here follows from one split.

Internal / dev Release / store
Made by make mobile-build-internal, fastlane device, assembleDebug make mobile-build-release, fastlane beta, bundleRelease
Goes to the operator's own phone TestFlight / Play internal testing
Dev↔prod tier switch present, in Settings › About absent
Dev API base (tailnet host) baked in absent
App icon muted full colour
__MOBILE_INTERNAL__ true false

A store build is a release build. This has not always been true, and the gap is recorded below because the symptom was invisible.

iOS → TestFlight

make ios-fastlane-install      # once per machine
make ios-testflight-preflight  # creds + app record + signing; builds nothing, fails in seconds
make ios-testflight            # release web build -> cap sync -> archive -> upload

preflight exists so a credential problem costs seconds instead of a ten-minute archive. Run it first on any machine that has not shipped before.

Credentials — web/learning-player/ios/fastlane/.env (gitignored; see .env.example):

Variable What
ASC_KEY_ID App Store Connect key id
ASC_ISSUER_ID the issuer UUID above the key list — one per team, not per key
ASC_KEY_PATH absolute path to AuthKey_<KEY_ID>.p8
APPLE_TEAM_ID 10-char Developer Team ID

The .p8 downloads from Apple exactly once. Keep it outside the repo.

The .p8 trap. An App Store Connect key and an APNs key are both .p8 files holding an EC P-256 private key with a 10-character id. They are not interchangeable, and using the ASC key where APNs is expected produces 403 InvalidProviderToken — an error that reads like a signing bug. See "Push" below.

Android → Play internal testing

make android-fastlane-install  # once per machine
make android-play-preflight    # creds + app record; builds nothing
make android-play              # release web build -> signed AAB -> upload to `internal`

make android-bundle alone produces the signed AAB without uploading.

The first upload cannot be automated. Play refuses an API upload for a package it has never seen, so the first artifact for app.closelistening.player goes up by hand in the Play Console, once. android-play-preflight detects this and says so, rather than letting the first upload fail with an opaque 404.

Credentials — web/learning-player/android/fastlane/.env (gitignored):

Variable What
SUPPLY_JSON_KEY absolute path to the Play service-account JSON

Grant that service account "Release to testing tracks" and nothing more. It exists to push internal builds; its blast radius should say so.

Signing — android/keystore.properties (gitignored), or the four ANDROID_KEYSTORE_* / ANDROID_KEY_* environment variables, which take precedence and are what CI would use.

storeFile=/absolute/path/to/upload-keystore.jks
storePassword=…
keyAlias=upload
keyPassword=…

When nothing is configured the release signing config is not created, and android-bundle refuses before building. That is deliberate: an AAB silently self-signed with the debug key is worse than one that fails, because Play rejects it anyway and only after the upload completes.

Back the keystore up. Once an app is published signed with a key, losing that key means losing the ability to update that listing, permanently.

Versioning is mechanical, never hand-edited: versionCode is ANDROID_VERSION_CODE when set — the hook for "ask Play for the last one and add one", the trick the iOS lane plays with latest_testflight_build_number — and otherwise the git commit count, which is monotonic and needs no network. versionName comes from package.json. Play rejects a duplicate versionCode after the upload finishes, which is why neither is typed by hand.

The Android toolchain on this build Mac

Three things that are not obvious and each cost a debugging cycle.

Homebrew cannot supply any of it. Homebrew dropped Intel x86_64 support in September 2026 and no longer builds bottles for it; /usr/local/Cellar is also not writable by the build user. The working route is a Temurin tarball unpacked anywhere readable.

It needs JDK 21, not 17. AGP 8.13's own floor is 17, but a Capacitor plugin pins a toolchain 21 requirement. With 17, Gradle resolves every dependency and then dies in :capacitor-filesystem:compileDebugJavaWithJavac with Cannot find a Java installation … matching {languageVersion=21} — an error that reads like a missing dependency rather than a wrong JDK.

JAVA_HOME is pinned in the Makefile (ANDROID_JAVA_HOME), not inherited from the caller's shell. A build that works only for whoever exported it is a build that fails confusingly for everyone else, CI included.

Component Where
Temurin JDK 21 ~/tools/jdk-21.*/Contents/Home (override: ANDROID_JAVA_HOME)
Android SDK ~/Library/Android/sdk (override: ANDROID_SDK_DIR)
Platform / build-tools platforms;android-36, build-tools;36.0.0 — match variables.gradle

What a store build must not contain

mobile-build-release asserts three things on the built artifact, because each was at some point assumed and each assumption was wrong:

OK: preview gate credential absent from the release bundle
OK: tier switch absent from the release bundle
OK: no private tailnet hostname in the release bundle

The reasoning is in the original credential check and applies to all three: a build-time substitution reaches the bundle, and it only disappears if the bundler happens to fold the branch. That is an optimisation, not a guarantee — so verify it at the one moment it matters.

The bug these were written to catch

mobile-build-release ran:

MOBILE_RELEASE=1 npm install && npm run build

A variable prefix binds to one command. npm install does not read MOBILE_RELEASE; npm run build — the only command that does — ran without it. So __MOBILE_INTERNAL__ was true in every release build ever produced, and the prod-locking the target advertises never happened. Two things shipped because of it: the dev↔prod tier switch, and the build host's private tailnet hostname (resolveDevApiBase() derives it from the build machine when VITE_DEV_API_BASE is unset). Fixed with export, and the assertions above now stand where the assumption was.

A related trap: the tier switch would not have been removed even with the flag right. A static import plus a runtime v-if inside a component puts that component in the bundle unconditionally and gates only its rendering. It is now imported dynamically behind the raw __MOBILE_INTERNAL__ constant — not isInternalBuild(), which computes the same answer through a cross-module call the bundler cannot see through.

Telling the two apps apart on one phone

Internal builds carry a muted app icon — same artwork, drained saturation. Regenerate with:

python scripts/tools/make_internal_icon.py

Android picks it up from the debug build type's resources. iOS sets it in the Debug build configuration (ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-Internal), alongside that build's own bundle id and display name — see below.

Push

Working on both platforms since 2026-09-30 — a real notification has been delivered to a real handset on each. What is NOT proven is the nudge path: every send so far called the transport directly with the outbox empty, so resurface-nudge.v1 -> outbox -> worker -> APNs/FCM has never run.

iOS. The long-running failure was the credential, not the code: apns_key_id held an App Store Connect API key, not an APNs key. Both are ES256 .p8 files with a 10-character id and they are indistinguishable by inspection — the ASC key even returns HTTP 201 to App Store Connect while returning 403 InvalidProviderToken to APNs. Three real sends failed that way before anyone looked. The two live in different halves of Apple's site:

Where Used for
APNs key developer.apple.com -> Keys sending notifications
ASC API key App Store Connect -> Users and Access -> Integrations uploading builds

To tell a key apart without a device, send to APNs with an all-zeros token: 403 InvalidProviderToken means the key is wrong, 400 BadDeviceToken means the key is right and only the dummy token was rejected.

apns_sandbox: false is correct for TestFlight/App Store builds — their tokens are production tokens. Dev-signed builds are sandbox and route through the podcast-dev tenant, whose apns_bundle_id must be the .dev id (see below), because apns-topic has to equal the bundle id the token belongs to.

Android. FCM, via a service account in the delivery worker. google-services.json is gitignored and keyed by package name, so a build for a different applicationId needs its own client added in the Firebase console. A release build without the file is fatal under -PandroidPushRequired=true (which make android-bundle sets).

Confirm the aps-environment entitlement is production for Release, or push silently fails on a TestFlight build regardless of everything else.

The debug build has its own identity

iOS identifies an app by bundle id alone, so a local build sharing the shipped id replaces the TestFlight app on the home screen rather than sitting beside it. The Debug configuration therefore ships:

Release Debug
bundle id app.closelistening.player app.closelistening.player.dev
display name Close Listening CL Dev
icon AppIcon AppIcon-Internal (muted)
APNs environment production development

Consequences worth knowing: the two apps have separate storage, so the dev build starts signed out with no downloads; IOS_BUNDLE_ID and the UI-test suite default to the .dev id (override with LP_UITEST_BUNDLE_ID); and the podcast-dev delivery tenant's apns_bundle_id must match it.

Android has no equivalent split yet (#2208) — debug and release share app.closelistening.player, and because they are signed by different keys adb install -r fails with INSTALL_FAILED_UPDATE_INCOMPATIBLE. The only way forward is uninstalling the Play build, which takes its data with it. Fixing it needs an applicationIdSuffix ".dev" plus a matching Firebase client, since google-services.json is keyed by package name — so the console step has to come first or every Android build breaks.

Beta testers need accounts

RFC-120 made the app login-first and the allowlist gates every sign-in, not just account creation — so a tester who is not on it cannot get in, and a build nobody can sign into is not a beta.

Add one without a deploy (#2190):

curl -X PUT https://closelistening.app/api/app/admin/access-policy \
  -H 'content-type: application/json' -b "$SESSION_COOKIE" \
  -d '{"mode":"allowlist","allowed_emails":["you@example.com","tester@example.com"]}'

It is a full replacement, so include every address that should keep working — including your own. The endpoint refuses a policy that would lock the caller out, and warns when it excludes another bootstrap admin. PLAYER_ALLOWED_EMAILS remains the bootstrap seed, used only when no policy file exists.

Removing an address stops the next sign-in; it does not end a live session (30-day cookie). To cut someone off immediately, PATCH /api/app/admin/users/{id} with disabled: true.