iOS auto-release CI/CD (GitHub Actions)
Org-standard signing credentials live in
org-credentials.envnext to this file (~/.claude/skills/ios-auto-release/org-credentials.env):ASC_KEY_ID,ASC_ISSUER_ID,ASC_PRIVATE_KEY_PATH,TEAM_ID=GU9WTSTX9M.install_on_device.shsources it as a fallback, so no need to hunt sibling.envfiles or ask the user. The.p8secret itself stays only at~/.appstoreconnect/private_keys/. A project's own.envoverrides these (setASC_BUNDLE_ID). To install a dev build on a connected device:bash ~/.claude/skills/ios-auto-release/scripts/install_on_device.sh.
Make every push to main build, sign, upload, and submit for review automatically.
The runner is macOS (Xcode is required to archive iOS apps). Auth for upload + submit is
the existing App Store Connect API key — no new token is needed (though a dedicated CI key
is good hygiene: Users and Access → Integrations → App Store Connect API → +).
The app record must already exist on App Store Connect (the API cannot create apps). Use the
app-store-submissionskill for the first manual submission; this skill automates the rest.
Fast path: upload a binary now via the ASC API (no CI round-trip)
To get a build into App Store Connect immediately from your Mac — instead of waiting on a
GitHub Actions run — use upload_binary.sh. It archives → exports an app-store-connect IPA →
uploads via xcrun altool --apiKey, reusing the org ASC API key as the upload token (no
Transporter/notarytool password). The build lands under the app's TestFlight / Builds tab a
few minutes later; attach it to a version and submit (UI, or ci_submit.py submit).
# app record + App ID + "Apple Distribution" cert + "<Scheme> App Store" profile must already exist
ASC_BUNDLE_ID=com.tertiaryinfotech.<app> \
bash ~/.claude/skills/ios-auto-release/scripts/upload_binary.sh
# Optional overrides: SCHEME, PROFILE, SIGN_IDENTITY, ENTITLEMENTS (e.g. a no-iCloud
# Local.entitlements), BUILD_NUMBER, SKIP_ARCHIVE=1 (re-export+upload an existing archive).
It auto-sources org-credentials.env + a project .env, signs manually with your
distribution cert + App Store profile, stamps a unique UTC build number, and warns about the
two most common altool rejections (missing AppIcon → 90022/90713) before the slow upload.
Use this for first releases and one-off pushes; use the GitHub Actions workflow below to make it
automatic on every push to main.
What it does each push
- Selects Xcode 26+ (App Store now rejects builds made with an older SDK).
- Imports the distribution certificate (
.p12) + App Store provisioning profile from secrets. - Computes the version + a unique build number, archives, exports (manual signing), uploads via
altool. - Waits for processing, then submits for review, reusing the in-review version or creating the next one
(carrying metadata + screenshots) — see
ci_submit.py.
Files to drop into the target repo
.github/workflows/ios-release.yml← fromtemplates/ios-release.yml(edit theenv:block)scripts/ci_submit.py← fromscripts/ci_submit.py(generic; no edits needed)ci/screenshots/APP_IPHONE_67/*.png← 1–3 real 6.9" screenshots, used when a new version is created
Edit the workflow's env: to your app: ASC_BUNDLE_ID, TEAM_ID, and (in the archive step)
the scheme, PROVISIONING_PROFILE_SPECIFIER, and CODE_SIGN_IDENTITY (usually Apple Distribution).
Gather the secrets (one-time)
REPO=<owner>/<repo>
KEYID=<ASC_KEY_ID>; ISSUER=<ASC_ISSUER_ID> # App Store Connect API key
P8=~/.appstoreconnect/private_keys/AuthKey_$KEYID.p8
# 1) ASC API key (upload + submit token)
gh secret set ASC_KEY_ID --repo $REPO --body "$KEYID"
gh secret set ASC_ISSUER_ID --repo $REPO --body "$ISSUER"
gh secret set ASC_API_PRIVATE_KEY --repo $REPO < "$P8"
# 2) Distribution certificate (cert + private key) as a password-protected .p12
# (exports the identities from your login keychain non-interactively)
security export -k ~/Library/Keychains/login.keychain-db -t identities \
-f pkcs12 -P "ci-temp-pass" -o /tmp/dist.p12
gh secret set DIST_CERT_PASSWORD --repo $REPO --body "ci-temp-pass"
base64 -i /tmp/dist.p12 | gh secret set DIST_CERT_P12_BASE64 --repo $REPO
# 3) App Store provisioning profile (download via ASC API or Xcode; install path below)
PROF="$HOME/Library/MobileDevice/Provisioning Profiles/<YourApp>_App_Store.mobileprovision"
base64 -i "$PROF" | gh secret set PROVISIONING_PROFILE_BASE64 --repo $REPO
Then gh secret list --repo $REPO should show all six. Trigger with a push or
gh workflow run ios-release.yml --repo $REPO.
Hard-won gotchas (already handled in the templates)
- iOS 26 SDK required. App Store Connect rejects uploads built with an older SDK
(409 "must be built with the iOS 26 SDK or later"). The workflow runs
maxim-lobanov/setup-xcodewithlatest-stable; ensure the runner image actually has Xcode 26 (macos-15/macos-26). - Export compliance must be answered or
submit409s. A build uploaded withoutITSAppUsesNonExemptEncryptioninInfo.plistlands withusesNonExemptEncryption = null(the encryption question is unanswered). Submitting that version fails with409 STATE_ERROR.ENTITY_STATE_INVALID— "appStoreVersions … is not in valid state … This resource cannot be reviewed" — with the real cause buried inassociatedErrorspointing at/v1/builds. Fix permanently: addITSAppUsesNonExemptEncryption=falsetoInfo.plist(XcodeGen: under the target'sinfo.properties) so every future build auto-answers. Fix a build already uploaded:PATCH /v1/builds/{id}{"attributes":{"usesNonExemptEncryption":false}}.ci_submit.pynow does this automatically inattach_build(setsfalsewhennull); usetrueonly if the app genuinely uses non-exempt encryption (then file the annual self-classification). - Externally-managed Python on macOS runners.
pip3 installfails with PEP 668; useactions/setup-python(the template does) andpip install. - Release archive defaults to automatic signing and fails for iCloud/other entitlements with the
team wildcard profile. The template forces
CODE_SIGN_STYLE=Manual+ an explicit profile. - Build number must be unique & increasing. The template uses a UTC timestamp
date -u +%y%m%d%H%M. - Marketing version must match the App Store version string for the build to attach —
ci_submit.pysets both (reuse the editable version, or bump the last component when the latest is released). - Review-submission race. Cancelling the current review is async and keeps "holding" the version,
so adding it to a new submission 409s.
ci_submit.pyretries, reusing an emptyREADY_FOR_REVIEWdraft instead of creating duplicates. - New versions need metadata + screenshots. When
ci_submit.pycreates the next version it copies the previous version's localization and uploadsci/screenshots/APP_IPHONE_67/*(commit real ones). - Release notes from
CHANGELOG.md.ci_submit.pyreads the section matching the version being submitted (falling back to[Unreleased]) and sets the version's What's New. It must run while the version is editable, so the script cancels the active review first, then sets it, then resubmits. This is a no-op on a first release (App Store allows "What's New" only on updates of an already-released app). - App Privacy + age rating are app-level and carry across versions — set once in the UI
(see the
app-store-submissionskill); CI does not touch them. - China mainland needs a publishing permit for any book/text content (Guideline 2.1). Apps that contain or display book, magazine, or scripture content — sutras, classics (道德经/金刚经), Tang poems, song lyrics, religious texts — are rejected in China mainland without a China NPPA Internet Publishing License (网络出版服务许可证). A Singapore/foreign entity (Tertiary Infotech) cannot obtain one, so deselect China mainland up front: Pricing and Availability → App Availability → Manage Availability → uncheck "China mainland" → Next → Confirm. The app then ships to every other territory and passes review; the choice is reversible if a permit is ever obtained. Make this the default for all Chinese-text / book-content apps to avoid a rejection round-trip. (This is an app-level availability setting, not a binary change — no rebuild needed; after removing China, just resubmit the rejected version with Update Review → Resubmit to App Review.)
Auto-install on your iPad on each release (local runner)
Cloud CI (GitHub Actions) cannot reach a physical iPad — it has no USB/network path to
your device. So on-device install of each release runs locally on your Mac (iPad connected,
Developer Mode on, device paired & trusted — see the ios-install-device skill).
Two pieces (in scripts/):
install_on_device.sh— builds a development copy of the current checkout and installs + launches it on all connected iPhones/iPads (matches byproductType, not the device's name). Regenerates the XcodeGen project and uses the.envASC key for-allowProvisioningUpdates. Restrict to specific devices by name withDEVICES, e.g.DEVICES="AA" bash …(iPad only). Run any time:bash .claude/skills/ios-auto-release/scripts/install_on_device.sh.This project's devices:
AA= iPad (iPad Air 11" M3),A2= iPhone (iPhone 16). By default a release installs on both; setDEVICES="AA,A2"(or a subset) to be explicit.watch_release_install.sh— pollsorigin/<branch>; when it advances (a new release in this CI's push-to-mainmodel) it checks that commit out in a throwawaygit worktreeand runs the installer. Your working tree is never touched (no pull/reset/checkout in place); the gitignored.envis copied into the worktree so signing works.
Run it as a LaunchAgent so each release auto-installs at login:
PLIST=~/Library/LaunchAgents/com.tertiaryinfotech.sketchbook.deviceinstall.plist
sed -e "s#__PROJECT_DIR__#$(pwd)#g" -e "s#__USER__#$USER#g" \
.claude/skills/ios-auto-release/templates/device-install.plist > "$PLIST"
launchctl load "$PLIST" # starts now + at every login; logs to /tmp/sketchbook-deviceinstall*.log
launchctl unload "$PLIST" # stop watching
Notes:
- It installs a development build (not the App Store binary — those are distribution-signed and can't sideload). It tracks the same source the release was cut from, so the device matches the release.
- Prefer tag-based releases? Set
BRANCHto a tag ref or adapt the watcher togit fetch --tags. - The Mac must be awake with the iPad reachable; if the device is offline a cycle is skipped and retried.
Caution
Auto-submitting every push to App Store review is aggressive (Apple dislikes churn). Prefer
gating on a tag or a manual workflow_dispatch if the user wants controlled releases — change the
on: trigger accordingly. The template submits on push to main (with doc/CI paths ignored).