The publishing guide covered the CI pipeline but never mentioned that production access is gated behind a closed test: 12 testers opted in continuously for the preceding 14 days, for personal developer accounts created after 13 Nov 2023. Record what the window actually requires -- install under the matching account, real devices, an unbroken 14 days, and the feature-usage and feedback evidence the application form asks for. Flag the track mismatch: android-release.yml uploads to the internal track, which does not count toward the requirement. Builds have to reach the closed track, either by promoting each release in the Console or by repointing tracks: in the workflow.
9.5 KiB
Google Play Auto-Publish Setup
How to configure GitHub secrets so pushing a v*.*.* tag builds a signed
AAB/APK, attaches both to a GitHub Release, and uploads the AAB to the Play
Console Internal track automatically. Driven by
.github/workflows/android-release.yml.
Verified working: tag v0.0.2 (2026-08-05) built, released, and uploaded to
the internal track end-to-end.
Prerequisites (one-time, manual)
These cannot be automated:
- Google Play Console account ($25 one-time) at play.google.com/console
- App entry created with package name
com.miti99.loto - First AAB uploaded manually to the Internal Testing track via the Play Console UI — Google requires the first upload to be manual
- Store listing completed (icon, screenshots, descriptions, content rating, privacy policy URL, data safety form)
Signing secrets (required for any release build)
| Secret | Description |
|---|---|
KEYSTORE_BASE64 |
Base64 of the PKCS12 keystore file |
KEYSTORE_PASSWORD |
Keystore password |
KEY_ALIAS |
Key alias (list aliases: keytool -list -keystore your.p12) |
KEY_PASSWORD |
Key password |
Encode the keystore:
# bash / Git Bash
base64 -w0 miti99-apps.p12 | gh secret set KEYSTORE_BASE64 -R tiennm99/loto
# PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\to\miti99-apps.p12")) |
gh secret set KEYSTORE_BASE64 -R tiennm99/loto
Set the remaining three with gh secret set <NAME> -R tiennm99/loto (prompts
for the value interactively, keeping it out of shell history).
Play Store auto-publish: the 5 steps
Do this after the first manual AAB upload has been accepted.
How the pieces fit: the service account lives in a Google Cloud project, but its authority to publish comes from Play Console, not from Cloud IAM. The GCP side only creates an identity and a key; the Play Console side grants the actual publishing rights. That is why the service account needs zero IAM roles in the Cloud project.
Step 1 — Create a GCP project and enable the API
- Open console.cloud.google.com and sign in with any Google account (it does not have to be the Play Console owner account).
- Project picker (top bar) → New Project → name it e.g.
loto-play-publishing→ Create. Reusing an existing project is fine. - APIs & Services → Library → search Google Play Android Developer API → Enable.
Or with the gcloud CLI:
gcloud projects create loto-play-publishing
gcloud config set project loto-play-publishing
gcloud services enable androidpublisher.googleapis.com
Step 2 — Create the service account (no IAM roles)
- IAM & Admin → Service Accounts → Create service account.
- Name: e.g.
play-publisher(email becomesplay-publisher@loto-play-publishing.iam.gserviceaccount.com). - "Grant this service account access to project" — skip it. Leave the role empty; publishing rights come from Play Console in Step 4. Granting Editor/Owner here is a common mistake that only widens the blast radius if the key leaks.
- "Grant users access to this service account" — skip it too. Done.
gcloud iam service-accounts create play-publisher \
--display-name "Play Store publisher (CI)"
Step 3 — Download a JSON key
- On the service account row → ⋮ → Manage keys (or the Keys tab) → Add Key → Create new key → JSON → Create. The file downloads once and cannot be re-downloaded — treat it like a password.
- Keep it outside the repo (e.g.
~/secrets/). It goes into a GitHub secret in Step 5 and can be deleted locally afterwards.
gcloud iam service-accounts keys create service-account.json \
--iam-account play-publisher@loto-play-publishing.iam.gserviceaccount.com
Step 4 — Add the service account to Play Console
Done in Play Console by the account owner (or an admin who can manage users):
- Copy the service-account email from Step 2.
- play.google.com/console → Users and permissions → Invite new users.
- Paste the service-account email. No invitation email is sent for service accounts — access activates as soon as you save.
- Under App permissions tab → Add app → select Lo To
(
com.miti99.loto) — scope access to this one app instead of account-wide permissions. - Tick exactly these two permissions:
- View app information and download bulk reports (read-only) — the API needs it to read the app's edit state
- Release apps to testing tracks — enough for the workflow's
tracks: internalupload Leave everything else (production releases, store presence, financial data, user management) unchecked. If you later automate production rollout (tracks: production), come back and add Release to production, exclude devices, and use Play App Signing.
- Invite user → Send invite.
Propagation is usually instant, but the very first API call can take up to ~24 h after the account's first-ever manual upload; if the workflow fails with a 401/403 right after setup, wait and re-run before changing anything.
Step 5 — Store the JSON as a GitHub secret
Name it PLAY_SERVICE_ACCOUNT_JSON:
# bash / Git Bash
gh secret set PLAY_SERVICE_ACCOUNT_JSON -R tiennm99/loto < path/to/service-account.json
# PowerShell — '<' redirection is NOT supported, pipe instead:
Get-Content -Raw C:\path\to\service-account.json |
gh secret set PLAY_SERVICE_ACCOUNT_JSON -R tiennm99/loto
The workflow is gated on this secret: if it is missing, the Play upload step skips silently and the run still produces a GitHub Release. This means tags work before Play setup is finished.
Cutting a release
-
Bump both values in
android/android/app/build.gradle— Play rejects duplicateversionCodes:versionCode 3 // must increase every release versionName "0.0.3" // should match the tag -
Commit, push, tag:
git add android/android/app/build.gradle git commit -m "chore(android): bump version to 0.0.3 (versionCode 3)" git push origin main git tag v0.0.3 git push origin v0.0.3 -
Watch and verify:
gh run list -R tiennm99/loto --workflow android-release.yml --limit 1 gh run watch <run-id> -R tiennm99/loto --exit-status gh release view v0.0.3 -R tiennm99/loto
Promotion beyond the internal track (closed → open → production) stays manual
in the Play Console UI, or change tracks: internal in
android-release.yml to automate further.
Closed testing → production access
com.miti99.loto ships from a personal developer account created after
13 Nov 2023, so Play gates production behind a closed test. Organization
accounts and personal accounts older than that date are exempt.
The gate, verbatim from Play Console Help: at least 12 testers opted in to your closed test, continuously, for the 14 days preceding the application.
What counts:
- "Opted in" means the tester accepted the invite and installed the build under the matching Google account. Invited-but-never-installed does not count.
- Real devices and genuine accounts only. Emulators and duplicate accounts do not count.
- The 14 days must be unbroken. A tester who opts out mid-window stops counting, and re-opting in restarts their 14-day clock. Dropping to 11 resets the window — 12 is the floor, not a safe target. Recruit spares.
- Since April 2026 Play also rejects applications for weak engagement. The form asks whether testers exercised all features and how their usage compared to expected production behaviour, plus a summary of their feedback. Collect that feedback as the test runs; it is painful to reconstruct afterwards.
Review of the production application typically takes up to 7 days.
The CI track does not feed the closed test. android-release.yml
uploads to tracks: internal. Internal testing is a separate track and
does not count toward the 12-tester requirement — only a closed test
does. So today a tagged release reaches the internal track, and someone
must promote that build to the closed track in the Play Console for
testers to receive it. Either promote manually each release, or switch
tracks: in the workflow to the closed track's name.
Troubleshooting
- Play upload fails with duplicate versionCode —
versionCodeinandroid/android/app/build.gradlewas not bumped before tagging. The '<' operator is reserved for future use— PowerShell does not support<input redirection; use theGet-Content -Raw ... |form above.- Play upload step skipped —
PLAY_SERVICE_ACCOUNT_JSONsecret is not set (checkgh secret list -R tiennm99/loto), or was set after the run started. - Play upload fails with 401/403 — either the service account was not invited in Play Console (Step 4), the app was not added under its App permissions, or the Play Developer API access has not finished propagating (can take up to ~24 h after the first-ever manual upload). Verify the invite, wait, then re-run the job.
- First upload rejected — Google requires the very first AAB to be uploaded manually through the Play Console UI; the API can only publish after that.
Never commit *.jks, *.keystore, *.p12, service-account JSON, or .env.