Shipping an Android app used to mean building a signed AAB, opening the Play Console, clicking through five screens, and hoping you didn't fat-finger a release note. The google play publishing api turns that whole ceremony into a script your CI can run. This is a practical, code-first walkthrough: the edits lifecycle, promoting releases between tracks, per-locale release notes, how it interacts with the 14-day closed-testing requirement, and how to handle the errors you'll actually hit.
The edits API lifecycle
The Google Play publishing API turns manual release steps into a CI script.
Everything in the Publishing API revolves around edits — transactions. The pattern is always the same:
-
Insert an edit → you get an
editId. - Mutate — upload bundles/APKs, assign tracks, set release notes, update listings.
- Commit the edit → changes go live. Or delete it → nothing happens.
Until you commit, nothing is visible to users. That makes edits safe to build up in CI: if a step fails, delete the edit and no harm is done.
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
PACKAGE = "com.example.myapp"
creds = service_account.Credentials.from_service_account_file(
"play-api-key.json", scopes=SCOPES)
api = build("androidpublisher", "v3", credentials=creds)
def new_edit():
return api.edits().insert(body={}, packageName=PACKAGE).execute()["id"]
Note the prerequisite that trips up most first-timers: your service account email must be invited under Play Console → Users and permissions with release rights. The JSON key alone gets you a 403.
Uploading a bundle and assigning it to a track
edit_id = new_edit()
# Upload the AAB produced by your Gradle build
bundle = api.edits().bundles().upload(
packageName=PACKAGE, editId=edit_id,
media_body="app/build/outputs/bundle/release/app-release.aab",
).execute()
vc = str(bundle["versionCode"])
# Ship it to the internal track
api.edits().tracks().update(
packageName=PACKAGE, editId=edit_id, track="internal",
body={"releases": [{
"name": f"CI #{vc}",
"versionCodes": [vc],
"status": "completed",
}]},
).execute()
api.edits().commit(packageName=PACKAGE, editId=edit_id).execute()
print(f"Shipped versionCode {vc} to internal")
Valid track names: internal, closed, open, and production. (Alpha/beta are legacy aliases — use the modern names.)
Promoting a release: internal → closed → production
Promotion is just assigning the same version codes to a new track in a fresh edit:
Promotion moves the same version codes from internal to closed to production tracks.
def promote(version_codes, from_track, to_track, rollout_fraction=None):
edit_id = new_edit()
release = {
"name": f"Promote {','.join(version_codes)}",
"versionCodes": version_codes,
"status": "completed",
}
if rollout_fraction is not None:
release["status"] = "inProgress"
release["userFraction"] = rollout_fraction
api.edits().tracks().update(
packageName=PACKAGE, editId=edit_id, track=to_track,
body={"releases": [release]},
).execute()
api.edits().commit(packageName=PACKAGE, editId=edit_id).execute()
# Staged rollout: 10% today, bump later
promote(["42"], "closed", "production", rollout_fraction=0.1)
A sane pipeline: merge to main → internal automatically; tag a release candidate → closed; after soak time and green crash metrics → production with userFraction: 0.1, then 0.5, then status: "completed". Each step is one CI job.
Release notes per locale
Release notes live on the release object under releaseNotes, a list of {language, text}:
notes = [
{"language": "en-US", "text": "Fixed crash on Android 14 share sheet.\nImproved offline sync."},
{"language": "de-DE", "text": "Absturz beim Teilen unter Android 14 behoben.\nOffline-Sync verbessert."},
]
api.edits().tracks().update(
packageName=PACKAGE, editId=edit_id, track="production",
body={"releases": [{
"name": "v2.4.0",
"versionCodes": ["42"],
"status": "completed",
"releaseNotes": notes,
}]},
).execute()
Tip: generate these from your changelog or conventional-commit messages in CI, with a fallback default per locale. Production releases can be rejected if required locales lack notes.
The 14-day closed-testing interaction
For new developer accounts, Google requires a 14-day closed test before production access is granted. The Publishing API does not bypass this — but it does make the test itself painless: script nightly uploads to the closed track, manage your tester list via edits.testers() (email lists or Google Groups), and when day 14 arrives, promote the exact same version codes to production with one call. Teams that automate this spend the two weeks actually testing instead of doing console chores. If coordinating the tester cohort itself is the hard part, closed testing coordination is one of those things worth delegating so your pipeline work isn't blocked on logistics.
Error handling for common API failures
-
Version code conflicts (
versionCode ... has already been used): your CI must guarantee monotonically increasing version codes. Derive them from the CI build number:versionCode = 10000 + buildNumber, or similar. Never reuse. - 403 permission errors: service account missing from Play Console Users and permissions, or lacking release rights for that track. Also check the Cloud project is linked under Setup → API access.
-
Edit conflicts (
edit is already committed/ track locked): a previous run left an uncommitted edit. List open edits, and delete stale ones before inserting a new edit. Wrap pipeline steps so a failure always cleans up. -
APK/Bundle validation errors: usually a signing problem — wrong keystore, or a debug-signed bundle. Fail fast in CI with
jarsigner -verify. - Release notes missing: add the default-locale fallback mentioned above.
-
Quota errors (429): back off and retry with jitter; don't poll
edits.getin a tight loop.
Common API failures include version conflicts, 403 errors, and stale edits.
A robust pattern: wrap the whole flow in try/except, delete the edit on any exception, and post the failure (with the API's error message, which is usually informative) to your team's channel.
Putting it together in CI
GitHub Actions sketch:
- name: Build release AAB
run: ./gradlew bundleRelease
- name: Publish to Play
env:
PLAY_KEY: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
run: |
echo "$PLAY_KEY" > /tmp/play-key.json
python scripts/play_publish.py --track internal
The script reads the track from an argument, so the same code promotes through closed and production on later workflow dispatches. Jenkins users can do the equivalent with a credentials binding and a pipeline stage.
Once this is running, releases become boring — which is exactly the point. The API handles the mechanics; your team handles the product.
About the author: This guide was written by the team at Play Console Store, an independent marketplace helping Android developers with Google Play publishing — account setup, verification, and closed testing. Not affiliated with or endorsed by Google LLC.
Top comments (0)