# IOS Auto Release

> Set up CI/CD that auto-builds, signs, uploads, and submits a native iOS/iPadOS app to App Store Connect on every push to main (GitHub Actions on macOS). Use when a user wants automated App Store releases / "auto submit a build on push" / a release pipeline for an Xcode app. Reuses the App Store Connect API key as the upload+submit token; handles signing assets as GitHub secrets, the iOS-26-SDK requirement, and the review-submission race.

- Skill: `alfredang/ios-auto-release` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add alfredang/ios-auto-release`
- Raw SKILL.md: https://api.skillmd.com/api/skills/alfredang/ios-auto-release/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- License: MIT
- Author: alfredang (https://skillmd.com/u/alfredang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/alfredang/ios-auto-release

---


# iOS auto-release CI/CD (GitHub Actions)

> **Org-standard signing credentials** live in `org-credentials.env` next 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.sh` sources it as a fallback,
> so no need to hunt sibling `.env` files or ask the user. The `.p8` secret itself stays only at
> `~/.appstoreconnect/private_keys/`. A project's own `.env` overrides these (set `ASC_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-submission` skill 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`).

```bash
# 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

1. Selects **Xcode 26+** (App Store now rejects builds made with an older SDK).
2. Imports the **distribution certificate** (`.p12`) + **App Store provisioning profile** from secrets.
3. Computes the version + a unique build number, archives, exports (manual signing), uploads via `altool`.
4. 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`  ← from `templates/ios-release.yml` (edit the `env:` block)
- `scripts/ci_submit.py`               ← from `scripts/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)

```bash
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-xcode`
  with `latest-stable`; ensure the runner image actually has Xcode 26 (`macos-15`/`macos-26`).
- **Export compliance must be answered or `submit` 409s.** A build uploaded *without*
  `ITSAppUsesNonExemptEncryption` in `Info.plist` lands with `usesNonExemptEncryption = null`
  (the encryption question is unanswered). Submitting that version fails with
  `409 STATE_ERROR.ENTITY_STATE_INVALID` — *"appStoreVersions … is not in valid state … This
  resource cannot be reviewed"* — with the real cause buried in `associatedErrors` pointing at
  `/v1/builds`. **Fix permanently:** add `ITSAppUsesNonExemptEncryption=false` to `Info.plist`
  (XcodeGen: under the target's `info.properties`) so every future build auto-answers. **Fix a
  build already uploaded:** `PATCH /v1/builds/{id}` `{"attributes":{"usesNonExemptEncryption":false}}`.
  `ci_submit.py` now does this automatically in `attach_build` (sets `false` when `null`); use `true`
  only if the app genuinely uses non-exempt encryption (then file the annual self-classification).
- **Externally-managed Python on macOS runners.** `pip3 install` fails with PEP 668; use
  `actions/setup-python` (the template does) and `pip 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.py`
  sets 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.py` retries, reusing an empty `READY_FOR_REVIEW`
  draft instead of creating duplicates.
- **New versions need metadata + screenshots.** When `ci_submit.py` creates the next version it copies
  the previous version's localization and uploads `ci/screenshots/APP_IPHONE_67/*` (commit real ones).
- **Release notes from `CHANGELOG.md`.** `ci_submit.py` reads 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-submission` skill); 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 by `productType`, not the device's name).
  Regenerates the XcodeGen project and uses the `.env` ASC key for `-allowProvisioningUpdates`.
  Restrict to specific devices by **name** with `DEVICES`, 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**; set `DEVICES="AA,A2"` (or a subset) to be explicit.
- **`watch_release_install.sh`** — polls `origin/<branch>`; when it advances (a new release in this
  CI's push-to-`main` model) it checks that commit out in a **throwaway `git worktree`** and runs the
  installer. **Your working tree is never touched** (no pull/reset/checkout in place); the gitignored
  `.env` is copied into the worktree so signing works.

Run it as a **LaunchAgent** so each release auto-installs at login:

```bash
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 `BRANCH` to a tag ref or adapt the watcher to `git 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).

