Skip to content

Staged rollouts on Google Play with GitHub Actions

Staged rollouts are the safest way to ship an Android release: start at 1% of production, watch the crash rate, ramp to 10%, 20%, 50%, 100%. Doing it manually in the Play Console is tedious. Doing it in CI, gated on real vitals, is where you actually want to live.

This post walks through a complete GitHub Actions workflow using gplay that:

  1. Uploads a signed AAB to the internal track.
  2. Promotes to production at 1% rollout after a soak period.
  3. Ramps to 20% if crashes stay clean.
  4. Ramps to 100% if week-over-week crashes are within tolerance.
  5. Halts the rollout automatically if crashes regress.

Save this as .github/workflows/release.yml:

name: Release to Google Play
on:
push:
tags: ['v*']
workflow_dispatch:
env:
GPLAY_PACKAGE: com.example.app
GPLAY_NO_UPDATE: 1
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install gplay
run: |
curl -sSL https://raw.githubusercontent.com/tamtom/play-console-cli/main/install.sh | bash
echo "$HOME/.gplay/bin" >> $GITHUB_PATH
- name: Write service-account key
env:
PLAY_SA_JSON: ${{ secrets.PLAY_SA_JSON }}
run: |
echo "$PLAY_SA_JSON" > $RUNNER_TEMP/play-sa.json
echo "GPLAY_SERVICE_ACCOUNT=$RUNNER_TEMP/play-sa.json" >> $GITHUB_ENV
- name: Build AAB
run: ./gradlew bundleRelease
- name: Upload to internal track
run: |
gplay release \
--track internal \
--bundle app/build/outputs/bundle/release/app-release.aab \
--release-notes "en-US=$(git log -1 --pretty=%B)"
soak:
needs: release
runs-on: ubuntu-latest
steps:
- name: Wait 24 hours in internal
run: sleep 86400
- name: Check internal crashes
env:
GPLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SA_JSON_PATH }}
run: |
CRASH_USERS=$(gplay vitals crashes query \
--time-range LAST_24_HOURS \
| jq '[.clusters[].distinctUsers // 0] | add')
if [ "${CRASH_USERS:-0}" -gt 50 ]; then
echo "Internal soak failed: $CRASH_USERS crash-affected users"
exit 1
fi
promote-1pct:
needs: soak
runs-on: ubuntu-latest
steps:
- name: Promote to production at 1%
env:
GPLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SA_JSON_PATH }}
run: |
gplay tracks promote \
--from internal \
--to production \
--rollout 0.01
- name: Notify Slack
run: |
gplay notify send \
--webhook "${{ secrets.SLACK_WEBHOOK }}" \
--message "🟢 Production rollout started at 1% for ${{ github.ref_name }}"
ramp-20pct:
needs: promote-1pct
runs-on: ubuntu-latest
steps:
- name: Soak at 1% for 24h
run: sleep 86400
- name: Gate on crash regression
env:
GPLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SA_JSON_PATH }}
run: |
THIS=$(gplay vitals crashes query --time-range LAST_24_HOURS \
| jq '[.clusters[].distinctUsers // 0] | add')
BASELINE=$(gplay vitals crashes query --time-range PREVIOUS_7_DAYS \
| jq '[.clusters[].distinctUsers // 0] | add / 7')
if [ "${THIS:-0}" -gt $(( ${BASELINE:-0} * 130 / 100 )) ]; then
gplay tracks halt-rollout --track production
gplay notify send --webhook "${{ secrets.SLACK_WEBHOOK }}" \
--message "🚨 Rollout halted: crashes up >30% vs 7-day baseline"
exit 1
fi
- name: Ramp to 20%
env:
GPLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SA_JSON_PATH }}
run: |
gplay tracks update-rollout --track production --rollout 0.20
ramp-100pct:
needs: ramp-20pct
runs-on: ubuntu-latest
steps:
- name: Soak at 20% for 48h
run: sleep 172800
- name: Full rollout
env:
GPLAY_SERVICE_ACCOUNT: ${{ secrets.PLAY_SA_JSON_PATH }}
run: |
gplay tracks update-rollout --track production --rollout 1.0
gplay notify send --webhook "${{ secrets.SLACK_WEBHOOK }}" \
--message "🎉 100% rolled out for ${{ github.ref_name }}"

release — builds the AAB with Gradle and uploads to the internal track. Because we don’t set --rollout, internal ships at 100% (the standard for tester tracks).

soak — 24-hour hold at internal. Fails the workflow if crash-affected users exceed 50 (a hard number appropriate for a small internal audience; tune to your scale).

promote-1pctgplay tracks promote copies the internal release to production at 1% rollout. Note --rollout takes a fraction (0.01 = 1%), not a percentage.

ramp-20pct — 24-hour soak at 1%, then compare this-week crash-affected users against the previous 7-day average. If we’re 30% or more above baseline, gplay tracks halt-rollout freezes production and Slack gets pinged. Otherwise ramp to 20%.

ramp-100pct — 48-hour soak at 20%, then push to 100%.

If something looks off between automated stages:

Terminal window
# Halt the current rollout (holds at current fraction)
gplay tracks halt-rollout --track production
# Resume when ready
gplay tracks resume-rollout --track production
# Full rollback: promote the previous production build back in
gplay tracks promote --from production --to production \
--from-version-code 4210 --rollout 1.0

The workflow above uses the latest commit message as the en-US release note. For multi-locale releases:

Terminal window
gplay release \
--track production \
--bundle app-release.aab \
--release-notes-file release-notes.yaml

Where release-notes.yaml is:

en-US: "Bug fixes and improvements"
fr-FR: "Corrections de bugs et améliorations"
de-DE: "Fehlerbehebungen und Verbesserungen"
es-ES: "Correcciones de errores y mejoras"
ja-JP: "バグの修正と改善"

Or let your AI agent generate them from git history:

Terminal window
gplay release-notes generate --since v1.4.0 --output release-notes.yaml

The PLAY_SA_JSON secret should contain the full contents of your service-account key file. If your org policy forbids storing JSON as a secret, use OIDC to fetch it from Google Secret Manager at job start — gplay reads whatever path GPLAY_SERVICE_ACCOUNT points to.

Never commit the JSON to the repo.

Why this ends up cleaner than a Fastlane pipeline

Section titled “Why this ends up cleaner than a Fastlane pipeline”
  • One binary, one install step. No Ruby, no bundler, no Fastfile.
  • JSON output — the vitals gating step is a jq one-liner, not a scraper.
  • Halted rollouts are first-classhalt-rollout / resume-rollout / update-rollout are separate commands with clean semantics.
  • Same tool for post-release monitoring. Same CLI runs the promotion, gates on vitals, and pings Slack. One dependency.
Terminal window
brew install tamtom/tap/gplay
gplay setup --auto

Full track reference at /reference/tracks/, vitals at /reference/vitals/. If you want a starter Actions workflow for your project, install the rollout-management skill and ask your AI agent to scaffold it against your app’s package name and typical release cadence.