permissions:
contents: read
deployments: write
steps:
- name: Advance staged rollout
id: rollout
uses: appshipkit/play-rollout-manager@v1
with:
package-name: com.example.app
service-account-json: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
steps: '10,25,50,100'
slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Print result
env:
ACTION_TAKEN: ${{ steps.rollout.outputs.action-taken }}
REASON: ${{ steps.rollout.outputs.reason }}
PREVIOUS: ${{ steps.rollout.outputs.previous-percentage }}
PERCENTAGE: ${{ steps.rollout.outputs.percentage }}
run: |
echo "$ACTION_TAKEN: $REASON"
echo "$PREVIOUS% -> $PERCENTAGE%"A zero-dependency JavaScript action (Node 24) that manages the staged rollout of the current release on a Google Play track through the Google Play Android Developer API v3. Google Play is the single source of truth: every run reads the track from Play, decides what to do, and either commits the change or throws the edit away. Nothing about the rollout state is stored anywhere else.
Every run follows the same flow:
- Sign an RS256 JWT with the service account key (
node:crypto) and exchange it at the key'stoken_urifor an access token. The token is masked in the log; the key and token are never printed. POST /editsto open an edit, thenGET /edits/{id}/tracks/{track}.- Pick the release with status
inProgressorhalted. If only acompletedrelease exists, the rollout is done.version-nameis that release'snameandversion-codesare itsversionCodes. - Decide what to do from
commandand the safety rules below. - Apply it with
PUT /edits/{id}/tracks/{track}andPOST /edits/{id}:commit, orDELETE /edits/{id}when there is nothing to change ordry-runis on. - Record applied changes as GitHub deployments, write outputs and a step summary table, and notify Slack.
Every HTTP response is checked. Failures stop the run with Google's (or GitHub's) error message, the edit is deleted, and Slack is notified before the step fails.
| command | What it does |
|---|---|
advance |
Moves an inProgress release to the smallest value in steps that is strictly greater than the current percentage. Reaching 100 sets the release to completed and removes userFraction. |
set |
Moves an inProgress release to percentage (1-100, 100 completes it). Lowering the percentage is refused; halt the release instead. |
halt |
Sets an inProgress release to halted, keeping its percentage. |
resume |
Sets a halted release back to inProgress, keeping its percentage. |
complete |
Sets an inProgress release to completed. |
status |
Reads the track and reports, changes nothing. |
When a release becomes completed, any older completed release on the same track is removed from the
PUT body, so the track ends with one completed release.
advancenever touches ahaltedrelease; it reportsskipped: release is halted.- Only
resumeun-halts a release.setandcompleteon a halted release are skipped withrun resume first. advanceskips when the last rollout increase for this package and track happened less thanmin-hours-between-stepshours ago (see Where state lives below).advanceonly increases the rollout onallowed-days(default Monday to Friday) intimezone(defaultUTC). On other days it reportsskipped: today is Saturday in UTC; allowed-days is mon-fri. The crash guard still halts on any day, andset,halt,resume,completeandstatusalways run.setnever lowers the rollout percentage.statusanddry-runnever change anything in Google Play and never create deployments.- If the GitHub Deployments API fails, the rollout still proceeds with a warning, unless
require-history: true. - If
max-crash-rateis set and the crash rate of the release is above it,advancehalts the release instead of advancing it (experimental, see below).
| name | description | required | default |
|---|---|---|---|
| package-name | Application package name, e.g. com.example.app. |
Yes | β |
| service-account-json | Contents of the service account JSON key. Pass it from a secret. | Yes | β |
| track | Play track holding the staged release. | No | production |
| command | advance, set, halt, resume, complete or status. |
No | advance |
| steps | Comma separated rollout percentages used by advance. Values must be greater than 0 and at most 100. |
No | 10,25,50,100 |
| percentage | Target percentage for set (1-100, 100 completes the rollout). |
No | β |
| min-hours-between-steps | advance skips when the last recorded increase is newer than this many hours. 0 disables the check. |
No | 20 |
| allowed-days | Days on which advance may increase the rollout: names (mon, Monday), ranges (mon-fri, wrap-around fri-mon), lists (mon,wed,fri) or all. |
No | mon-fri |
| timezone | IANA time zone used to decide today's day for allowed-days, e.g. UTC, Europe/London, Asia/Kolkata. |
No | UTC |
| max-crash-rate | Experimental. Crash rate in percent (e.g. 1.5). When set, advance halts the release if its crash rate is higher. |
No | β |
| changes-not-sent-for-review | Sends changesNotSentForReview=true with the edit commit. |
No | false |
| dry-run | Compute and report the decision only. The edit is deleted, nothing is recorded. | No | false |
| github-token | Token used for GitHub deployments. Needs deployments: write. |
No | github.token |
| deployment-environment | GitHub environment that holds the rollout history. | No | play-<track>-rollout |
| require-history | Fail instead of warning when GitHub deployments cannot be read or written. | No | false |
| slack-webhook-url | Slack incoming webhook URL. Pass it from a secret. | No | β |
| notify-on-skip | Also notify Slack when the run skips. | No | false |
| notify-on-dry-run | Send Slack notifications in dry-run mode. | No | false |
The inputs api-base-url, token-url-override and github-api-url are for testing only. They point the
action at a mock server instead of the Google and GitHub APIs (CI uses them, see Development below).
api-base-url replaces both https://androidpublisher.googleapis.com and
https://playdeveloperreporting.googleapis.com. Do not set them in real workflows.
- version-name :-
nameof the release on the track, e.g.1.2.3. - version-codes :- Comma separated version codes of the release, e.g.
123,124. - status :- Release status after the run:
inProgress,haltedorcompleted. - previous-percentage :- Rollout percentage before the run (
100when completed). - percentage :- Rollout percentage after the run.
- action-taken :-
advanced,set,halted,resumed,completed,skippedornone(forstatus). - reason :- Human readable explanation of the decision.
In dry-run the outputs describe what would have happened and reason starts with dry run, not applied:.
Every run also writes a step summary table with the state before and after.
With slack-webhook-url set, a message is posted for advanced, completed, halted, resumed, set and for
failures. Skips are posted only with notify-on-skip: true, and nothing is posted in dry-run unless
notify-on-dry-run: true. Each message has the package, track, version name, the percentage change, the reason
and the workflow run URL:
:arrow_up: *Rollout advanced*: com.example.app (production) 1.2.3
Rollout: 25% β 50%
Reason: advanced from 25% to 50%
Run: https://github.com/OWNER/REPO/actions/runs/123456789
A failing Slack webhook only produces a warning; it never fails the rollout.
examples/auto-rollout.yml runs every weekday at 16:00 UTC and on demand. The scheduled
job only runs when the repository variable AUTO_ROLLOUT_ENABLED is true:
if: github.event_name == 'workflow_dispatch' || vars.AUTO_ROLLOUT_ENABLED == 'true'- Turn on: Settings β Secrets and variables β Actions β Variables, create
AUTO_ROLLOUT_ENABLEDwith valuetrue, or rungh variable set AUTO_ROLLOUT_ENABLED --body true. - Turn off: set it to
false(gh variable set AUTO_ROLLOUT_ENABLED --body false). Scheduled runs are then skipped, manual runs still work. - Turn everything off: open the workflow in the Actions tab, click β¦ and Disable workflow
(or
gh workflow disable auto-rollout.yml). Re-enable it the same way. - Manual commands: Actions β Play staged rollout β Run workflow and pick
advance,set(withpercentage),halt,resume,completeorstatus, optionally withdry-run. From the CLI:
gh workflow run auto-rollout.yml -f command=status
gh workflow run auto-rollout.yml -f command=set -f percentage=20
gh workflow run auto-rollout.yml -f command=halt
gh workflow run auto-rollout.yml -f command=advance -f dry-run=trueadvance only increases the rollout on allowed-days, which defaults to mon-fri, so nothing rolls further over
a weekend even if the workflow is scheduled daily or someone runs advance by hand on a Saturday. The day is
taken in timezone (default UTC); set it to your team's zone so "Friday" means your Friday:
allowed-days: mon-thu
timezone: Europe/LondonUse allowed-days: all to allow every day. Days outside the list are reported as skipped (sent to Slack only
with notify-on-skip: true). The crash guard still halts a bad release on any day, and the manual set, halt,
resume, complete and status commands are never blocked.
Halting a release in Play Console works too: the next scheduled run reads the real status from Play and skips the
halted release until someone runs resume.
-
Google Play is the source of truth. The current release, its status and its percentage are always read from the track at the start of the run. There is no state in repository variables, files or caches, so a change made in Play Console (halt, new release, manual percentage change) is respected by the next run.
-
History lives in GitHub deployments. Every applied change creates a deployment in the
deployment-environmentenvironment (defaultplay-<track>-rollout) withtask: play-rollout,refset to the workflow commit and this payload:{"packageName":"com.example.app","track":"production","versionName":"1.2.3","versionCodes":["123"], "fromPercent":25,"toPercent":50,"command":"advance","action":"advanced"}followed by a
successstatus with a description like1.2.3 25% β 50%and a link to the run. Browse it in Code β Environments β play-production-rollout (or the Deployments page). -
min-hours-between-stepsuses the newest deployment withtask: play-rolloutwhose payload matches the package and track and whose action increased the rollout (advanced,set,completed). Halts and resumes do not reset the clock, and the deployments GitHub creates for a job's ownenvironment:are ignored because they use a different task.
- In Google Cloud, create (or pick) a project and enable the Google Play Android Developer API. For the crash guard also enable the Google Play Developer Reporting API.
- Create a service account in that project and add a JSON key. Store the whole JSON file contents as a GitHub
secret, e.g.
PLAY_SERVICE_ACCOUNT_JSON(an environment secret onplay-production-rolloutkeeps it scoped to rollout jobs). - In Play Console open Users and permissions, choose Invite new users and enter the service account
email (
[email protected]). - Under App permissions add the app and grant Release to production, exclude devices, and use Play App Signing (and Release apps to testing tracks if you manage testing tracks). For the crash guard also grant View app information and download bulk reports (read-only).
- Invite the user. Permissions can take a while to become active; the first
statusrun confirms access. - Give the workflow
permissions: deployments: writeso the history can be recorded.
Experimental. The request below follows the published Play Developer Reporting API reference but has only been tested against a mock. Verify the response shape against a real account with
dry-run: truebefore relying on it.
When max-crash-rate is set, the JWT requests both the androidpublisher and playdeveloperreporting scopes,
and advance on an inProgress release first:
-
GET https://playdeveloperreporting.googleapis.com/v1beta1/apps/{packageName}/crashRateMetricSetand readsfreshnessInfo.freshnesses[]whereaggregationPeriodisDAILYto find the last complete day (latestEndTime). -
POST https://playdeveloperreporting.googleapis.com/v1beta1/apps/{packageName}/crashRateMetricSet:querywith:{ "timelineSpec": { "aggregationPeriod": "DAILY", "startTime": {"year": 2026, "month": 2, "day": 28, "timeZone": {"id": "America/Los_Angeles"}}, "endTime": {"year": 2026, "month": 3, "day": 1, "timeZone": {"id": "America/Los_Angeles"}} }, "dimensions": ["versionCode"], "metrics": ["crashRate", "distinctUsers"], "filter": "versionCode = 123 OR versionCode = 124" }endTimeislatestEndTimeandstartTimeis one day earlier, in the time zone Play returns. -
Reads
rows[].dimensions[](dimension: "versionCode",stringValue) androws[].metrics[](metric: "crashRate",decimalValue.value, a fraction such as0.012for 1.2%). The worst rate across the release's version codes is compared withmax-crash-rate.
- Above the threshold: the release is halted instead of advanced and Slack is notified.
- No data for the release's version codes (or no freshness info): the run skips without advancing.
- Reporting API errors (API disabled, missing permission): the run fails and notifies, so a broken guard is noticed instead of silently ignored.
set, complete, halt, resume and status do not consult the crash guard.
-
examples/auto-rollout.ymladvances the production rollout every weekday at 16:00 UTC whenAUTO_ROLLOUT_ENABLEDistrue, and offers every command throughworkflow_dispatch. It uses theplay-production-rolloutenvironment and a concurrency group so two runs never overlap. -
examples/halt-on-alert.ymlhalts the rollout on arepository_dispatchevent of typehalt-rollout(or manually), for wiring to an alerting system:curl -X POST \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/OWNER/REPO/dispatches \ -d '{"event_type":"halt-rollout","client_payload":{"reason":"Crash rate alert"}}'
The token needs
contents: writeon the repository. The halt workflow uses its own concurrency group so a queued scheduled run can never cancel a pending halt. If a halt and an advance commit edits at the same moment, Play rejects one of them; the failure is reported to Slack and the halt can simply be run again.
Copy one into .github/workflows/ of your repository and replace com.example.app.
npm testThe action has no npm dependencies; it uses only Node 24 built-ins (fetch, node:crypto, node:fs). The tests
use node:test, stub fetch with recorded request and response pairs, and generate throwaway RSA keys at run
time. They cover the token exchange (JWT header, claims and signature), the edit sequences per command, step
math, the safety rules, deployment history, dry-run, Slack payloads, error surfacing and the crash guard.
tests/mock-server.js is a small HTTP server that imitates the token endpoint, the Play
edits API and the GitHub deployments API. Run it with MOCK_PORT=8787 node tests/mock-server.js and point the
action at it with the testing inputs; GET /__requests lists what it received.
| Workflow | Trigger | What it does |
|---|---|---|
| CI | push to main, pull requests |
ShellCheck, actionlint, node --check, unit tests on Node 24, and an integration run of the action (uses: ./, dry-run: true) against the mock server with a throwaway key, asserting outputs and that nothing was written. |
| Release | manual (patch / minor / major) |
Runs CI, calculates the next version from the highest vX.Y.Z tag, publishes a GitHub release with generated notes, and moves the vMAJOR tag. |
There is no CI job against the real Google Play API because it needs a real app and service account secret.