App Store release runbook
App Store release runbook
This runbook takes a HealthExporter build from TestFlight to the App Store. The owner starts every App Store Connect write. Each write runs from a workflow on the Linux runner; nothing runs from a laptop.
The workflows:
| Workflow | Does |
|---|---|
release-testflight.yml |
Tests, archives and uploads a build to TestFlight. Each release merge starts it. |
appstore-screenshots.yml |
Replaces the 6.9-inch iPhone screenshots of an App Store version. |
release-appstore.yml |
Checks an App Store version, attaches the build, sets the metadata and submits it for App Review. |
promote-testflight-external.yml |
Adds a build to the external TestFlight group and submits it for Beta App Review. |
Prerequisites
The App Store Connect API key
The workflows use one App Store Connect team API key. The repository secrets
are ASC_API_KEY_ID, ASC_API_ISSUER_ID and ASC_API_KEY_P8 (the .p8 file
text).
The key must have the App Manager role. A key with the Developer role can
upload builds, but App Store Connect refuses its review submissions and its
TestFlight promotions with 403 FORBIDDEN_ERROR.
The App Review contact
App Review needs a contact for each submission. release-appstore.yml reads
it from four secrets:
| Secret | Value |
|---|---|
ASC_REVIEW_CONTACT_FIRST_NAME |
First name |
ASC_REVIEW_CONTACT_LAST_NAME |
Last name |
ASC_REVIEW_CONTACT_EMAIL |
Email address |
ASC_REVIEW_CONTACT_PHONE |
Phone number: +, the country code, then the number, for example +1 555 0100 123 |
The owner sets them, as repository or organization secrets. gh secret set
asks for the value, so the value does not go into the shell history:
gh secret set ASC_REVIEW_CONTACT_FIRST_NAME --repo evanwtf/HealthExporter
gh secret set ASC_REVIEW_CONTACT_LAST_NAME --repo evanwtf/HealthExporter
gh secret set ASC_REVIEW_CONTACT_EMAIL --repo evanwtf/HealthExporter
gh secret set ASC_REVIEW_CONTACT_PHONE --repo evanwtf/HealthExporter
A set secret wins over the contact in App Store Connect. When a secret is not set, the script keeps the value that App Store Connect has. When neither has a value, the script refuses and names the missing secret. The script sends only the values from the secrets; it does not send a kept value again. The script never logs a contact value. It logs only “set from secret”, “kept” or “missing”.
Cut a public release
-
Merge the release pull request. The release job tags
vX.Y.Zand startsrelease-testflight.ymlon the tag. Check that the upload passed:gh run list --workflow release-testflight.yml --repo evanwtf/HealthExporter --limit 3 -
If the UI changed, replace the App Store screenshots. The TestFlight run of step 1 keeps its screenshots 3 days. If it is older, make a new set first with a dry run on the tag, and wait for it to pass:
gh workflow run release-testflight.yml --repo evanwtf/HealthExporter --ref v2.5.5 -f upload=false gh run list --workflow release-testflight.yml --repo evanwtf/HealthExporter --limit 1Then run the screenshot dry run, read the log, and upload:
gh workflow run appstore-screenshots.yml --repo evanwtf/HealthExporter -f version=2.5.5 gh workflow run appstore-screenshots.yml --repo evanwtf/HealthExporter -f version=2.5.5 -f dry_run=falseThe screenshot upload needs an editable App Store version. If the version does not exist yet, run step 4 (prepare) first. It creates the version.
-
Run
release-appstore.ymlin mode dry-run (the default). It writes nothing:gh workflow run release-appstore.yml --repo evanwtf/HealthExporter -f version=2.5.5Read the log. The plan names the build, the version, What’s New and the review submission. When a check fails, the run fails and lists every problem. Fix them all, then run the dry run again.
- App Review takes one version at a time. While another version is in review, or approved and not yet live, the run refuses and names it.
- The default build is the newest VALID, unexpired build of the version.
Pass
-f build=57to use another one. - What’s New comes from the version’s section of
docs/changelog.md, without the lines that start with “- Please”. Those lines ask testers to check something, so only TestFlight shows them. - For a version that the run creates, the script always uses the
changelog text: App Store Connect can copy What’s New from the version
before it. For a version that exists, the script keeps the text in App
Store Connect, and uses the changelog only when the text is empty. Pass
-f replace_whats_new=trueto use the changelog text in all cases. - When the version does not exist, the dry run cannot check its metadata. The prepare run creates it, then checks it before any other write.
- App Store Connect allows one editable version. When a lower version is in Prepare for Submission, the script renames it to the new version. It keeps that version’s metadata and screenshots.
-
Run mode prepare. It writes the version, the release type, the build, What’s New and the App Review contact. It does not submit:
gh workflow run release-appstore.yml --repo evanwtf/HealthExporter -f version=2.5.5 -f mode=prepare -
Open the version in App Store Connect and read the page: the build, What’s New, the screenshots and the App Review information.
-
Run mode submit. It runs the checks again, writes the same values, adds the version to a review submission and submits it:
gh workflow run release-appstore.yml --repo evanwtf/HealthExporter -f version=2.5.5 -f mode=submitThe default release type releases the version when App Review approves it. To release it by hand, add
-f release_type=manual.
Watch a run:
gh run watch --repo evanwtf/HealthExporter "$(gh run list --workflow release-appstore.yml --repo evanwtf/HealthExporter --limit 1 --json databaseId --jq '.[0].databaseId')"
Promote a build to external TestFlight
This step is optional. It gives the build to the external testers before, or in place of, an App Store release:
gh workflow run promote-testflight-external.yml --repo evanwtf/HealthExporter -f build=57
Add -f submit_review=false to add the build to the group without a Beta App
Review submission. Add -f group=<name> to use one group only.
Verify a release went in
-
The
release-appstore.ymlrun passed, and its last log line is “Submitted version X.Y.Z (build N) for App Review”.gh run list --workflow release-appstore.yml --repo evanwtf/HealthExporter --limit 3 -
Run the dry run again. It must now refuse, with “already in review (WAITING_FOR_REVIEW)” and the version state. That refusal shows that App Store Connect has the submission:
gh workflow run release-appstore.yml --repo evanwtf/HealthExporter -f version=2.5.5 -
In App Store Connect, the version shows “Waiting for Review”. Apple also sends an email to the account holder.
-
After approval, a version with release type after-approval goes to “Ready for Distribution” without a further step. A version with release type manual waits in “Pending Developer Release” until the owner releases it in App Store Connect.
When a submit run fails
Run mode submit again. If the failed run added the version to a draft review submission, the version is in “Ready for Review”. The script reuses that version and that draft, and does not add the version twice.
When App Review rejects the version
The dry run refuses with “has unresolved issues”. Read App Review’s message in App Store Connect, and answer it there.
- If App Review needs a new build, merge the fix. Then run mode prepare with the new version number. The script renames the rejected version only when it is in Prepare for Submission, so first remove the rejected version from review in App Store Connect.
- If App Review needs only an answer or a metadata change, resubmit in App Store Connect.
Rotate the ASC key
The key is a team key. Other apps of the team can use the same key, so update every repository that uses it before you revoke the old key.
- In App Store Connect, go to Users and Access, then Integrations, then App
Store Connect API, then Team Keys. Generate a key with the App Manager
role. Download the
.p8file. App Store Connect lets you download it once. - Keep the
.p8file in the team’s password manager. -
Set the three secrets. The key ID is on the Team Keys page. The issuer ID does not change.
gh secret set ASC_API_KEY_ID --repo evanwtf/HealthExporter gh secret set ASC_API_KEY_P8 --repo evanwtf/HealthExporter < AuthKey_NEWKEYID.p8 - Delete the local
.p8file. -
Test the new key with a dry run. It only reads:
gh workflow run release-appstore.yml --repo evanwtf/HealthExporter -f version=2.5.5A
401error means a wrong key ID, issuer ID or.p8text. A403error on a write means the key does not have the App Manager role. - Revoke the old key in App Store Connect.