DEV Community

Cover image for I built the first Android publishing CLI with Managed Google Play support
Yasser's studio
Yasser's studio

Posted on

I built the first Android publishing CLI with Managed Google Play support

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:

  1. Click through Google Play Console for every release (takes 1-2 hours)
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Open Play Console
  2. Click through the Create app wizard
  3. Manually enter title and language
  4. Upload the AAB
  5. Click through organization assignment
  6. Confirm a permanent-privacy warning
  7. Wait for Google's backend to process the upload
  8. 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;
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

The --account argument is the long integer you read from the Play Console URL:

https://play.google.com/console/developers/1234567890/...
                                             ^^^^^^^^^^
Enter fullscreen mode Exit fullscreen mode

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]:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. A Google Play developer account. The one whose ID you'll pass as --account.
  2. The Play Custom App Publishing API enabled in a Google Cloud project. Enable it here.
  3. 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.
  4. 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
Enter fullscreen mode Exit fullscreen mode

Look for:

✓ Play Custom App Publishing API is reachable
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)