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:
- Uploads a signed AAB to the internal track.
- Promotes to production at 1% rollout after a soak period.
- Ramps to 20% if crashes stay clean.
- Ramps to 100% if week-over-week crashes are within tolerance.
- Halts the rollout automatically if crashes regress.
The workflow
Section titled “The workflow”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 }}"What’s happening at each stage
Section titled “What’s happening at each stage”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-1pct — gplay 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%.
Manual rollback
Section titled “Manual rollback”If something looks off between automated stages:
# Halt the current rollout (holds at current fraction)gplay tracks halt-rollout --track production
# Resume when readygplay tracks resume-rollout --track production
# Full rollback: promote the previous production build back ingplay tracks promote --from production --to production \ --from-version-code 4210 --rollout 1.0Adding release notes
Section titled “Adding release notes”The workflow above uses the latest commit message as the en-US release note. For multi-locale releases:
gplay release \ --track production \ --bundle app-release.aab \ --release-notes-file release-notes.yamlWhere 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:
gplay release-notes generate --since v1.4.0 --output release-notes.yamlStoring the service account safely
Section titled “Storing the service account safely”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
jqone-liner, not a scraper. - Halted rollouts are first-class —
halt-rollout/resume-rollout/update-rolloutare separate commands with clean semantics. - Same tool for post-release monitoring. Same CLI runs the promotion, gates on vitals, and pings Slack. One dependency.
Getting started
Section titled “Getting started”brew install tamtom/tap/gplaygplay setup --autoFull 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.