If you ship a private Android app to enterprise customers, you've probably noticed something:
No publishing CLI supports it.
Fastlane supply, the most popular Android publishing tool, wraps the standard Google Play Publisher API. Gradle Play Publisher does the same. They both handle public Play Store releases well (upload AAB, promote tracks, manage rollouts), but neither touches the Play Custom App Publishing API (the API for publishing private apps to Managed Google Play).
If you need to publish a private app, you have two options today:
- Click through Google Play Console for every release (takes 1-2 hours)
- Write your own Google API client against the Custom App API from scratch
I just shipped v0.9.56 of GPC (a Play Console CLI I've been building for the past few months) with native support for this API. GPC is the first Android publishing CLI to wrap the Play Custom App Publishing API end to end.
Now option 3 exists:
gpc enterprise publish ./app.aab \
--account 1234567890 \
--title "My Internal App" \
--org-id customer-org
Five minutes instead of two hours.
What's a "private app" on Managed Google Play?
Managed Google Play is Google's app store for enterprise-managed Android devices. Apps fall into two buckets:
- Public apps are regular Play Store apps that enterprises can approve and distribute to their employees. Nothing special on the publishing side.
- Private apps are custom apps distributed exclusively to specific enterprise customers. Invisible to the public Play Store. Created via a separate Google API called the Play Custom App Publishing API.
This matters for you if you build:
- An internal app for your own company's managed Android fleet
- A vertical SaaS product with a per-customer Android client
- A line-of-business app that only one enterprise customer installs
- A private B2B tool distributed through Managed Google Play
If your app is public Play Store fare, skip this article. GPC's regular commands work for you and always have.
Why nobody wrapped this API
The Play Custom App Publishing API is tiny. Google's discovery document exposes exactly one method:
POST /playcustomapp/v1/accounts/{account}/customApps
That single method creates a new private app and uploads the bundle. There's no list method. No update method. No delete method. Apps created through this API are permanently private and can never be converted to public apps later.
Because the API is small and the audience is narrow, nobody has productized a wrapper for it. The tools that handle 99% of Android publishing (Fastlane, gradle-play-publisher) target the 99% use case: public Play Store releases.
But if you're in the 1% shipping to enterprise, every release is a painful manual slog in Play Console. The Custom App flow requires you to:
- Open Play Console
- Click through the Create app wizard
- Manually enter title and language
- Upload the AAB
- Click through organization assignment
- Confirm a permanent-privacy warning
- Wait for Google's backend to process the upload
- Copy the assigned package name somewhere so you can reference it later
One release takes 1-2 hours. If you're shipping weekly, that's 4-8 hours a month of manual clicking.
The multipart resumable upload gotcha
When I first tried to wrap this API, I hit an interesting challenge. The Play Custom App Publishing API uses multipart resumable upload: the JSON metadata (title, language code, target organizations) and the bundle binary travel together in a single resumable session.
The session-initiation POST carries the JSON metadata in its body. Subsequent PUT requests stream the binary in chunks to the session URI Google returns.
This is different from the standard Publisher API, which uses simple uploadType=media uploads where the body is just raw binary. GPC already had a resumable upload helper, but it was designed for the Publisher API pattern (empty initial POST body). I had to extend it:
// Before: initial session POST sent an empty body
// After: optional initialMetadata parameter drives a JSON body on init
export interface ResumableUploadOptions {
chunkSize?: number;
onProgress?: (event: UploadProgressEvent) => void;
// ... existing options
/**
* Optional JSON metadata to include in the initial session-initiation POST.
* When present, the initial request uses Content-Type: application/json; charset=UTF-8
* and the serialized body. When omitted, the initial request sends an empty body
* (default Publisher API behavior).
*/
initialMetadata?: object;
}
This generalization turned out to be valuable beyond just the Custom App API: any Google API that follows the same "metadata in init POST, binary in chunks" pattern can now use the same helper.
What the command actually looks like
The CLI surface is intentionally simple:
# One-shot publish (most common path)
gpc enterprise publish ./app.aab \
--account 1234567890 \
--title "My Internal Tool" \
--org-id customer-org \
--yes # skip confirmation in CI
# Explicit-arg form
gpc enterprise create \
--account 1234567890 \
--bundle ./app.aab \
--title "My Internal Tool" \
--org-id customer-org
# Multiple target organizations
gpc enterprise publish ./app.aab \
--account 1234567890 \
--title "Multi-Customer Client" \
--org-id org-acme --org-name "Acme Corp" \
--org-id org-beta --org-name "Beta Inc"
The --account argument is the long integer you read from the Play Console URL:
https://play.google.com/console/developers/1234567890/...
^^^^^^^^^^
It's not a Google Workspace organization ID. Not a Cloud Identity ID. Just the developer account ID. (This confused me for a while building the feature, and it confuses users hitting the docs for the first time too.)
One-way door: permanently private
Apps created through this API cannot be made public later. GPC prints a confirmation prompt before every create/publish call to make sure you meant it:
⚠ This will publish a PRIVATE app to Managed Google Play.
Private apps created via this API are permanently private and cannot be converted
to public apps later.
Developer account: 1234567890
Title: My Internal Tool
Language: en_US
Bundle: ./app.aab
Organizations: customer-org
Continue with private app creation? [y/N]:
Pass --yes to skip the prompt in CI or non-interactive environments. The CLI will refuse to proceed on a non-TTY without --yes, which prevents accidental creates from unattended scripts.
What happens after the initial publish
Here's the part that surprised me while building this feature: after a private app is created, it becomes a regular app in your developer account.
It has a packageName that Google assigns (something like com.google.customapp.A1B2C3D4E5). You can't influence the package name. But once the app exists, you can use every other GPC command against it:
# Save the package name from the create step
APP_ID=com.google.customapp.A1B2C3D4E5
# Upload a new version like any other app
gpc --app $APP_ID releases upload ./app-v2.aab --track production
# Promote between tracks
gpc --app $APP_ID releases promote --from beta --to production --rollout 10
# Sync listings
gpc --app $APP_ID listings push --dir metadata/
# Query crash rates
gpc --app $APP_ID vitals crashes --threshold 2.0
None of this requires the Custom App API. It all goes through the regular Play Publisher API, same as any other app in your account. The private app is private in distribution (only visible to the organizations you assigned at create time), but administratively it's just another draft app.
The only thing you can't do programmatically is add or remove target enterprise organizations after creation. Google's API doesn't expose that. You have to open Play Console UI and do it there.
CI/CD recipe
For teams that want to automate private app publishing from a pipeline, here's a working GitHub Actions example:
name: Publish private app
on:
workflow_dispatch:
inputs:
flow:
description: "Initial publish or version update?"
type: choice
options: [update, initial]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: npm install -g @gpc-cli/cli
- name: Write service account key
run: echo '${{ secrets.GPC_SA_KEY }}' > /tmp/sa.json
env:
GPC_SERVICE_ACCOUNT_KEY: /tmp/sa.json
- name: Initial publish
if: inputs.flow == 'initial'
run: |
gpc enterprise --account ${{ secrets.DEVELOPER_ACCOUNT_ID }} \
publish ./app.aab \
--title "Internal Tools" \
--org-id ${{ secrets.CUSTOMER_ORG_ID }} \
--yes
- name: Update existing
if: inputs.flow == 'update'
run: |
gpc --app com.google.customapp.A1B2C3D4E5 \
releases upload ./app.aab --track production
The workflow takes a choice input that routes to either the one-time gpc enterprise publish path or the ongoing gpc releases upload path.
Required setup (one-time)
Before you can run gpc enterprise publish for the first time, you need four things:
-
A Google Play developer account. The one whose ID you'll pass as
--account. - The Play Custom App Publishing API enabled in a Google Cloud project. Enable it here.
- A service account with the "create and publish private apps" permission in Play Console. This is a Play Console permission (Account permissions, not per-app), separate from Google Cloud IAM.
- Organization IDs for the enterprise customers you want to target. Your customers provide these.
GPC's gpc doctor command includes a probe for this API. Run it to verify setup before your first publish attempt:
gpc doctor
Look for:
✓ Play Custom App Publishing API is reachable
If the probe fails, the error message points at the specific setup step that's incomplete.
Install
# npm (Node.js 20+)
npm install -g @gpc-cli/cli
# Homebrew (macOS/Linux)
brew install yasserstudio/tap/gpc
# Standalone binary (no Node.js required)
curl -fsSL https://raw.githubusercontent.com/yasserstudio/gpc/main/scripts/install.sh | sh
Free to use
GPC's source is on GitHub. It's free to use for commercial and personal projects. If you hit a bug or have a feature request, open an issue.
Full release notes: https://github.com/yasserstudio/gpc/releases/tag/v0.9.56
Enterprise publishing guide: https://yasserstudio.github.io/gpc/guide/enterprise-publishing
All commands: https://yasserstudio.github.io/gpc/commands/
Next on the roadmap: a v0.9.57 batch of API client fixes (uncovered during an audit that led to the v0.9.56 enterprise rewrite), then v1.0.0 stable.
If you're shipping to Managed Google Play and GPC saves you time, I'd love to hear about it.
Top comments (0)