# Play closed testing (GitHub Actions) GitHub Actions is the Android analog of Xcode Cloud → TestFlight, and the OpenPocketCine counterpart of OpenZCine’s Play Internal pipeline: a signed App Bundle, a monotonic `versionCode`, and an upload onto Play **closed testing** (`alpha`). There is no Play equivalent of Apple-managed signing, so the upload keystore and the Play service account live in the **play-closed** GitHub Environment. Auto-upload on `main` is gated by the repository variable `ANDROID_PLAY_UPLOAD` (must be exactly `true`) — the same kill switch as OpenZCine `PLAY_UPLOAD_ENABLED`. Missing `play-closed` secrets **fail the job**. They do not skip-succeed. Join URL (once the first release is live): One-time Play Console, upload keystore, Play API robot, and GitHub secrets: ```bash just android-play-setup # walkthrough (Console + keystore + API robot + first AAB) just android-play-sync-secrets # non-interactive: keystore + play-closed secrets ``` ## Why closed testing | Track | Cap | Review | Use | | --- | --- | --- | --- | | Internal testing | 100 testers | None | Maintainer smoke | | **Closed testing (`alpha`)** | Large email lists | First release of a version | Waitlist (300+) | | Open testing | Anyone with the link | Yes | Not yet | Internal testing cannot hold the waitlist. Firebase App Distribution would skip Play review, but the waitlist copy and privacy policy already promise Play Console. Closed testing is the TestFlight-shaped path: opt-in URL, email list, Play-delivered updates. ## What the workflow does | Step | Where it lives | | --- | --- | | Swift core + JNI `.so` | [`scripts/ci-install-swift-android.sh`](../scripts/ci-install-swift-android.sh) + `:app:stageSwiftCore` | | Signed AAB | `./gradlew bundleRelease` with `ANDROID_KEYSTORE_*` | | `versionName` | [`Apps/Android/gradle.properties`](../Apps/Android/gradle.properties) `openpocketcine.versionName` (keep equal to iOS `MARKETING_VERSION`) | | `versionCode` | CI stamp: `ANDROID_VERSION_CODE_BASE` + `github.run_number` (local `openpocketcine.versionCode` is the sideload floor) | | Tester notes | [`Apps/Android/Play/WhatToTest.en-US.txt`](../Apps/Android/Play/WhatToTest.en-US.txt) | | Play "what's new" (500 chars) | [`Apps/Android/Play/whatsnew/whatsnew-en-US`](../Apps/Android/Play/whatsnew/whatsnew-en-US) | | Upload | `.github/workflows/android-play.yml` → track `alpha` | The workflow **never** runs on `pull_request`. Forks must not see the keystore or the service account (`github.repository` is pinned to `erik-sutton95/OpenPocketCine`). Auto-upload on `main` is off until `ANDROID_PLAY_UPLOAD=true`. Until then, **Run workflow** on **Android Play** or `just android-play-dispatch`. The first AAB of a new package must go through the Play Console UI so the package name exists. After that, Actions can upload. Keep `ANDROID_PLAY_UPLOAD` off until that first Console AAB exists and the Play API robot is invited. ## After the workflow exists - **Merges to `main`** that touch `Apps/Android/`, the Swift core, the JNI facade, `Package.swift`, the Android scripts, or `Apps/Android/Play/` upload once `ANDROID_PLAY_UPLOAD` is `true`. Docs-only commits do not. Auto-upload is fail-closed: all five `play-closed` secrets must exist and `PLAY_SERVICE_ACCOUNT_JSON` must be a service-account key. - **Dispatch inputs**: `alpha` (closed) or `internal` (smoke); `completed` or `draft`; `changes_not_sent_for_review` while the listing is still incomplete. Dispatch without `PLAY_SERVICE_ACCOUNT_JSON` still signs an AAB artifact (the first Console upload). Auto-upload never takes that shortcut. - **Next versionCode**: the stamp is monotonic because `github.run_number` only increases. Raise `ANDROID_VERSION_CODE_BASE` if you ever need to jump over a manual upload. - **Signed AAB artifact**: every run attaches `app-release--`. ## GitHub `play-closed` environment secrets | Secret | Value | | --- | --- | | `ANDROID_KEYSTORE_BASE64` | Base64 of `.local/play-upload.keystore` (no newlines) | | `ANDROID_KEYSTORE_PASSWORD` | Store password from `.local/play-signing.env` | | `ANDROID_KEY_ALIAS` | `upload` | | `ANDROID_KEY_PASSWORD` | Same as the store password (PKCS12) | | `PLAY_SERVICE_ACCOUNT_JSON` | Full contents of `.local/play-service-account.json` | `just android-play-sync-secrets` writes those from `.local/`. No keystore, password, or service-account JSON is ever committed. The Play API robot is **separate** from OpenZCine's `openzcine-play-publisher`. The wizard mints `openpocketcine-play-publisher@openpocketcine-play-publisher.iam.gserviceaccount.com` when `gcloud` is logged in, then you **Invite new users** on Play Console Users and permissions (same page as OpenZCine's robot). Play cannot create that invite from git. Leave the OpenZCine row alone. Repository variables: | Variable | Value | | --- | --- | | `ANDROID_PLAY_UPLOAD` | `true` to auto-upload on `main`; anything else is off | | `ANDROID_VERSION_CODE_BASE` | Offset added to `github.run_number` (default `0`) | Local signed bundle (same keystore as CI): ```bash just android-bundle ``` Dispatch a run from `main` without waiting for a merge: ```bash just android-play-dispatch ``` ## Tester-facing release notes **this-build** window by default, or the cumulative window since a maintainer-named open-beta build, using the same contract as TestFlight: [`tester-notes.md`](tester-notes.md). Play listing "what's new" is 500 characters. The longer What to Test file is operator copy for GitHub / email. Any pull request that can trigger a Play upload replaces `Apps/Android/Play/WhatToTest.en-US.txt` **and** `whatsnew-en-US` with this PR plus up to three other newest operator-visible `feat:` / `fix:` items Android testers can see. ```bash just tester-notes-window just android-play-notes ``` `scripts/android-release-notes-check.sh` enforces the format. Pull-request CI also verifies that the notes files changed when Android production paths changed. ## Version numbers | Field | Source | Example | | --- | --- | --- | | **Product version** (Play "Version") | `openpocketcine.versionName` | `0.1.0` | | **versionCode** (Play "Build") | CI stamp | `1`, `2`, … | Keep `openpocketcine.versionName` equal to iOS `MARKETING_VERSION`. Bump the product version only when starting a new train. Daily closed-testing uploads stay on the current train. See [`RELEASE.md`](RELEASE.md). The first closed release of the app sits in Play review; later builds of that version are often faster. ```bash just android-version ``` ## Store listing copy Short description (80 characters): ```text Open-source field monitor for DJI Osmo. Live view, scopes, camera control. ``` Full description (paste into Play Console): ```text OpenPocketCine is a free, open-source field monitor for DJI Osmo. Pair over Bluetooth, join the camera's Wi-Fi, and watch a live view with waveform, parade, histogram, and vectorscope. False colour, zebras, peaking, and LUTs sit on the picture. Record, ISO, white balance, zoom, and gimbal from the phone. This closed beta is arm64 only (64-bit phones, Android 10+). It is tested on Osmo Pocket 4 Pro. Pocket 4 and Pocket 3 are untested. There is no account in the app. Source: https://github.com/erik-sutton95/OpenPocketCine Docs: https://openpocketcine.app/docs/ Privacy: https://openpocketcine.app/privacy/ ``` Privacy policy URL: Support URL: Phone screenshots must be **Android** captures. Do not reuse iPhone marketing frames on the Play listing. ## Testers 1. Export the Tally waitlist (CSV). Keep it out of git. 2. `python3 scripts/prepare-android-testers.py ~/Downloads/tally.csv` 3. Play Console → Closed testing → Testers → upload `.local/play-testers.csv` Play also emails testers when they are added. ## Crash reports Play Console Vitals is the crash source once testers install from Play. There is no third-party crash SDK. Native Swift `.so` frames are easier to read if you keep a matching AAB from the Actions run. ## Troubleshooting | Symptom | Likely cause | | --- | --- | | Wizard keeps opening All apps / Configurations | It used to reopen `play.google.com/console` every stage. Re-run `just android-play-setup` after pulling; paste the OpenPocketCine address-bar URL once (`…/developers/…/app/…`). Left sidebar is the menu. | | Workflow skipped on `main` | `ANDROID_PLAY_UPLOAD` is not exactly `true`, or the commit did not touch Android paths | | Missing `play-closed` secrets | One of the five values below is absent; `just android-play-sync-secrets` | | `PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON key` | The secret is empty, truncated, or not a GCP service-account key | | Insufficient permissions | Invite `openpocketcine-play-publisher@…` on Users and permissions (OpenPocketCine app, testing-track release). Wait 15–60 minutes after Invite. | | Package not found | First AAB has not been uploaded in the Console yet | | Testers see nothing | Closed release still in review; or they have not tapped Become a tester | | "Device not compatible" | 32-bit phone, or Android older than 10 | | versionCode collision | Raise `ANDROID_VERSION_CODE_BASE` above the Play high-water mark |