permissions:
contents: read
deployments: write
steps:
- name: Pause phased release
id: phased
uses: appshipkit/appstore-phased-release@v1
with:
command: pause
issuer-id: ${{ secrets.ASC_ISSUER_ID }}
key-id: ${{ secrets.ASC_KEY_ID }}
private-key: ${{ secrets.ASC_PRIVATE_KEY }}
bundle-id: com.example.app
slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Show result
run: |
echo "${{ steps.phased.outputs.version }} is ${{ steps.phased.outputs.state }}"
echo "Day ${{ steps.phased.outputs.day }} (${{ steps.phased.outputs.percentage }}%)"A GitHub Action that controls the App Store phased release of an iOS app through the
App Store Connect API. It is the iOS
counterpart of play-rollout-manager and shares the same ideas: a command, a dry-run switch,
Slack notifications and deployment history in GitHub Environments.
The action finds the app (by app-id or by resolving bundle-id), picks the newest iOS version in
READY_FOR_SALE, PENDING_DEVELOPER_RELEASE or PROCESSING_FOR_APP_STORE that has a phased release
(or the exact version you pass) and then runs one of these commands:
| command | allowed from | result |
|---|---|---|
status |
any state | Reports state, day and percentage. Never changes anything. |
pause |
ACTIVE |
Sets the phased release to PAUSED. |
resume |
PAUSED |
Sets the phased release back to ACTIVE. |
complete |
ACTIVE, PAUSED |
Sets the phased release to COMPLETE, releasing to all users. |
guard |
ACTIVE |
Pauses when crash-free-rate is below min-crash-free-rate. |
From any other state the command is reported as skipped with a reason, and the API is never asked to
set a state the release is already in. guard never resumes a paused release; that stays a human decision.
Phased release only affects users with automatic updates turned on. Everyone can still download the new version manually from the App Store. Apple's schedule is fixed and cannot be customised:
| day | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| % | 1% | 2% | 5% | 10% | 20% | 50% | 100% |
The percentage output is derived from currentDayNumber with this table (COMPLETE is always 100%,
INACTIVE is 0%). INACTIVE means the version is approved but not released yet; this action does not
release versions, so commands other than status are skipped in that state.
A phased release can be paused for at most 30 days in total. The action shows totalPauseDuration
in the step summary and emits a warning (also included in Slack) once the total reaches 25 days.
Apple does not expose crash rates through the App Store Connect API, so guard takes the current
crash-free percentage as an input. Fetch it in an earlier step from your crash reporter and pass the step
output to crash-free-rate:
- Firebase Crashlytics: export Crashlytics to BigQuery and query the crash-free users ratio for the
version with
bq query --format=json, or read a value your own backend publishes. - Sentry: call the sessions endpoint
(
GET /api/0/organizations/<org>/sessions/?field=crash_free_rate(user)&query=release:<release>) with aSENTRY_AUTH_TOKENsecret and multiply the ratio by 100 (check the endpoint against the Sentry docs for your plan).
Write the number (for example 99.72) to $GITHUB_OUTPUT and reference it as
${{ steps.<id>.outputs.crash-free-rate }}. guard fails when crash-free-rate is empty, so a broken
metric step never looks healthy.
- In App Store Connect open Users and Access → Integrations → App Store Connect API.
- Create a Team key with the App Manager role or higher (Admin works too). Developer and Marketing roles cannot change phased releases.
- Note the Issuer ID and Key ID, and download the
AuthKey_<KEYID>.p8file (it can be downloaded only once). - Add repository or environment secrets:
ASC_ISSUER_ID,ASC_KEY_IDandASC_PRIVATE_KEY(the full contents of the.p8file including theBEGIN/ENDlines).
The action signs a short lived ES256 JWT (at most 20 minutes) with node:crypto, masks it in the log and
never prints the key.
Scheduled workflows are gated by a repository variable, so you can switch automation off without editing
YAML: Settings → Secrets and variables → Actions → Variables, set PHASED_RELEASE_GUARD_ENABLED to
true to enable and anything else to disable. Manual runs of the control workflow are always available.
App Store Connect is the single source of truth: every run reads the current phased release state from
Apple, so changes made by hand in App Store Connect are picked up automatically. The action keeps no
state of its own. Every applied change (not dry runs, skips or status checks) is recorded as a GitHub
Deployment with a success status in the deployment-environment (default appstore-phased-release),
for example 2.3.0 paused on day 3 (5%), so the history is visible under Environments in the repository.
Recording history needs deployments: write; a failure only warns unless require-history: true.
| name | description | required | default |
|---|---|---|---|
| command | status, pause, resume, complete or guard. |
No | status |
| issuer-id | App Store Connect API issuer ID. | Yes | ❌ |
| key-id | App Store Connect API key ID. | Yes | ❌ |
| private-key | Contents of the .p8 private key. Pass it from a secret. |
Yes | ❌ |
| app-id | Numeric Apple ID of the app. Either app-id or bundle-id is required. |
No | ❌ |
| bundle-id | Bundle identifier, resolved to the Apple ID when app-id is empty. |
No | ❌ |
| version | Version string to control. Empty picks the newest live or pending version with a phased release. | No | ❌ |
| crash-free-rate | Current crash-free percentage from your crash reporter. Required for guard. |
No | ❌ |
| min-crash-free-rate | guard pauses when crash-free-rate is below this percentage. |
No | 99.5 |
| dry-run | Compute and report only. Never changes the release, records history or sends success/skip notifications. | No | false |
| github-token | Token used to record deployment history. Needs deployments: write. |
No | github.token |
| deployment-environment | Environment the deployment history is recorded under. | No | appstore-phased-release |
| require-history | Fail the run when deployment history cannot be recorded. | No | false |
| slack-webhook-url | Slack incoming webhook URL, e.g. for #releases. |
No | ❌ |
| notify-on-healthy | Also notify when guard finds the release healthy. |
No | false |
| notify-on-skip | Also notify when a command is skipped. | No | false |
| api-base-url | Testing only. Base URL of the App Store Connect API. | No | https://api.appstoreconnect.apple.com |
| github-api-url | Testing only. Base URL of the GitHub REST API. | No | github.api_url |
Slack messages are sent for paused, resumed, completed and failures (always), for a healthy guard
only with notify-on-healthy, and for skipped commands only with notify-on-skip. Each message contains the
app, version, state, day, percentage, reason and a link to the workflow run. A failing webhook only warns.
- app-id :- Numeric Apple ID of the app.
- version :- Version string whose phased release was inspected, eg:
2.3.0. - state :- Phased release state after the run:
INACTIVE,ACTIVE,PAUSED,COMPLETE(orNONEwhen no phased release was found). - day :- Current day of the phased release (
1-7), empty when not started. - percentage :- Share of automatic-update users receiving the version, derived from the day.
- previous-state :- Phased release state before the run.
- action-taken :-
paused,resumed,completed,skippedornone(noneforstatus, a healthyguardand every dry run). - reason :- Why the action was taken, skipped or failed. Dry runs start with
Dry run: would ....
A table with the same values is written to the job summary.
Ready-to-copy workflows live in examples/:
phased-release-control.ymlis a manual workflow to check, pause, resume or complete the phased release, with adry-runswitch (on by default), deployment history in theappstore-phased-releaseenvironment and a concurrency group so two runs never race.scheduled-crash-guard.ymlruns every two hours on weekdays whenvars.PHASED_RELEASE_GUARD_ENABLED == 'true', reads the crash-free rate in a placeholder step (replace it with your Crashlytics or Sentry query) and pauses the release when it drops below99.5%.
Both expect the secrets ASC_ISSUER_ID, ASC_KEY_ID, ASC_PRIVATE_KEY and optionally SLACK_WEBHOOK_URL.
Using an environment lets you add required reviewers before anyone can complete a release.
node --test tests/*.test.jsThe action is plain JavaScript on Node 24 with no npm dependencies: it uses the global fetch and
node:crypto only, so there is nothing to install or bundle. Tests use node:test with a stubbed fetch
and cover the JWT, request shapes, version selection, every command and state combination, the guard,
the pause limit, deployment history and Slack payloads.
tests/mock-server.js is a small App Store Connect stand-in that verifies the JWT
signature. Run it next to the action with api-base-url to try things locally:
MOCK_PORT=8787 node tests/mock-server.js| Workflow | Trigger | What it does |
|---|---|---|
| CI | push to main, pull requests |
actionlint, node --check, unit tests on Node 24, and an integration run of the action in dry-run mode against the mock server with a throwaway EC key. |
| 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. |