| name | app-store-submission |
| description | End-to-end submission of a native iOS/iPadOS app to the App Store, driven almost entirely by the App Store Connect (ASC) API + Xcode CLI (no manual portal clicking where avoidable). Use when archiving, uploading a build, setting metadata/screenshots/pricing, and submitting an app for review. Covers the hard-won gotchas plus a field-tested App Review rejection checklist (real-app screenshots, in-app account deletion, working demo account). |
| license | MIT |
| metadata | {"version":"2.1.0"} |
App Store Submission (API-first)
Submit a native iOS/iPadOS app to the App Store with the App Store Connect (ASC) API
and the Xcode command line, doing as much as possible programmatically. This skill
captures a complete, repeatable workflow plus the non-obvious blockers that waste hours.
Use the bundled scripts in scripts/. Per-project values and the metadata copy
go in the project's .env (see .env.example) and the template at the end of
this doc. Placeholders below use <ANGLE_BRACKETS> — replace them with your own values.
What the API CAN and CANNOT do
API can: create/read the app record, set category & pricing, set version metadata
(description, keywords, subtitle, promo text, support/marketing URLs, copyright,
privacyPolicyUrl), create the App Review contact, upload builds (via altool),
attach a build, upload screenshots, create a review submission, and submit for review.
API CANNOT (must be done once in the web UI):
- App Privacy "nutrition label" (
appDataUsages). There is no public API — the
app resource exposes no appDataUsages relationship; every path 404s. Set it in the UI:
App Privacy → Get Started → declare what you collect (or "No, we do not collect data") → Publish.
- Age rating / content rights declarations are also effectively UI-only.
- Deleting an empty draft review submission returns 403 — harmless, leave or delete in UI.
Plan for one short UI visit per app for the App Privacy publish. Everything else is scriptable.
Prerequisites (one-time per Apple account)
-
Paid Apple Developer Program membership (accept the latest PLA in the portal).
-
Generate the App Store Connect API key — the ONE unavoidable portal step.
An ASC API key cannot be created via API (chicken-and-egg); the account holder must
generate it once in the web UI. After that, this skill drives everything else without
touching the portal. The exact clicks:
- Sign in at https://appstoreconnect.apple.com as the Account Holder / Admin.
- Users and Access → top tab Integrations → App Store Connect API →
Team Keys.
- Click + (Generate API Key). Name it (e.g. "automation"), set Access = Admin
(or at least App Manager), Generate.
- Download the
AuthKey_<ASC_KEY_ID>.p8 — this is offered only once. Save it to
~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 then chmod 600 it.
- Copy the Key ID (the 10-char id in the row) and the Issuer ID (UUID shown
above the keys list).
These three values are all the skill needs. If a key is ever lost/leaked, Revoke it
in the same screen and generate a new one.
-
Put the Key ID and Issuer ID in a local .env (gitignored) and point
ASC_PRIVATE_KEY_PATH at the .p8. See .env.example. The .p8 lives
outside the repo and is never committed (.gitignore excludes .env and *.p8).
ASC_KEY_ID=<ASC_KEY_ID>
ASC_ISSUER_ID=<ASC_ISSUER_ID>
ASC_PRIVATE_KEY_PATH=~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8
Load it before running scripts: set -a; source .env; set +a
The workflow
0. Pre-flight code checklist (in the repo)
- App icon 1024×1024, no alpha in the asset catalog.
CFBundleShortVersionString (marketing, e.g. 1.0) and CFBundleVersion (build, integer,
bump on every upload).
ITSAppUsesNonExemptEncryption = false in Info.plist (skips the export-compliance prompt)
— only if you use no non-exempt crypto.
- Usage-description strings for every permission (
NSMicrophoneUsageDescription, etc.).
UIRequiredDeviceCapabilities = arm64 (never the legacy armv7).
PrivacyInfo.xcprivacy privacy manifest (tracking false, collected types, required-reason APIs).
- For iPad-only:
TARGETED_DEVICE_FAMILY = 2. For iPhone-only: 1. Universal: 1,2.
- Per-config entitlements if using CloudKit/push: Debug →
aps-environment=development,
Release → production.
1. Archive + upload the build (Xcode CLI)
Replace <YourApp>.xcodeproj and scheme <YourApp> with your project's names.
Optional pattern — XcodeGen. If you generate the Xcode project with
XcodeGen from a project.yml, regenerate it first
(xcodegen generate) so version/build/bundle id/device family live in one source of truth,
and edit project.yml instead of the .pbxproj. This is entirely optional — a hand-managed
.xcodeproj works the same way for everything below.
xcodebuild -project <YourApp>.xcodeproj -scheme <YourApp> -configuration Release \
-archivePath /tmp/<YourApp>.xcarchive archive
xcodebuild -exportArchive -archivePath /tmp/<YourApp>.xcarchive \
-exportPath /tmp/export -exportOptionsPlist ExportOptions.plist
xcrun altool --validate-app -f /tmp/export/<YourApp>.ipa -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
xcrun altool --upload-app -f /tmp/export/<YourApp>.ipa -t ios \
--apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
altool reads the .p8 from ~/.appstoreconnect/private_keys/ automatically (the file is
AuthKey_<ASC_KEY_ID>.p8). Manual signing: set the <DISTRIBUTION_IDENTITY> signing
identity (e.g. "Apple Distribution: ") and the <PROVISIONING_PROFILE> profile
in ExportOptions.plist. Build processing takes ~5–30 min; poll until state is VALID.
2. Everything else (ASC API)
Use scripts/asc_submit.py — it loads .env, mints a JWT via
scripts/asc_jwt.swift, and exposes subcommands:
python3 scripts/asc_submit.py status
python3 scripts/asc_submit.py set-metadata
python3 scripts/asc_submit.py review-contact
python3 scripts/asc_submit.py attach-build --build 2
python3 scripts/asc_submit.py screenshots --type APP_IPAD_PRO_3GEN_129 a.png b.png
python3 scripts/asc_submit.py submit
3. Submit for review
submit creates a reviewSubmission, adds the version as a reviewSubmissionItem, then
PATCHes submitted=true. On success the version state becomes WAITING_FOR_REVIEW. The
command prints any blocker codes returned in associatedErrors.
4. CloudKit Production schema deploy (if the app uses CloudKit/SwiftData+CloudKit)
Not a review blocker, but ships broken sync if skipped. App Store builds use the
Production CloudKit environment; the schema you developed against is in Development.
In CloudKit Console → your container → Schema → Record Types → Deploy Schema Changes…,
review the Development→Production diff and Deploy.
- A record type only exists in the schema after a record of that type was created in the
Development environment. Production cannot auto-create new record types. So if a model
was never exercised in dev (e.g. a rarely-used record type), its type is absent and
that data won't sync until you create one record in a Debug build and re-deploy.
- If your app has no CloudKit (e.g. data lives on a backend REST API), skip this step.
Submission blockers cheat-sheet (the 409 associatedErrors)
| Blocker code / message | Fix |
|---|
appInfoLocalizations … privacyPolicyUrl required | PATCH appInfoLocalizations/{id} privacyPolicyUrl |
appStoreVersions … copyright required | PATCH appStoreVersions/{id} copyright (e.g. 2026 <Your Org>) |
appStoreReviewDetail … was not found | POST appStoreReviewDetails with contact name/phone/email, demoAccountRequired |
APP_DATA_USAGES_REQUIRED | UI-only: App Privacy → publish "Data Not Collected" (or fill labels) |
SCREENSHOT_REQUIRED.APP_IPHONE_65 | See the iPhone-screenshot quirk below |
Gotchas (the time-savers)
- iPhone 6.5" screenshot demanded for an iPad-only app. The API submission validator
spuriously requires an
APP_IPHONE_65 screenshot even when the binary is UIDeviceFamily=2.
The web UI usually won't ask, but the API will. Fastest unblock: generate valid
1242×2688 (or 1284×2778) images and upload them to an APP_IPHONE_65 set —
scripts/make_iphone_screenshot.swift frames an
existing iPad capture on a branded gradient so it looks intentional, not letterboxed.
Harmless for an iPad-only listing (the binary still determines device compatibility).
- A stale earlier build keeps the app "universal." If build 1 was uploaded universal
(before you set
TARGETED_DEVICE_FAMILY=2) and is still VALID, expire it
(PATCH /v1/builds/{id} expired=true) so it stops influencing device support.
- Screenshot upload is a 3-step dance, not a single PUT: (1)
POST /v1/appScreenshots
reserve with fileSize+fileName → returns uploadOperations; (2) PUT the bytes to each
operation's url with its requestHeaders; (3) PATCH /v1/appScreenshots/{id}
uploaded=true + sourceFileChecksum = MD5 hex of the file. Then poll
assetDeliveryState.state == COMPLETE.
- Bundle ID already taken → pick a namespaced reverse-DNS id you control
(
com.yourorg.appname); update the project (and the iCloud container, if any) to match.
- Device not registered / iCloud container mismatch when test-installing on hardware →
register the device UDID in the portal and ensure the iCloud container is created and
assigned to the App ID.
- JWT lifetime ≤ 20 min (
exp = iat + 1200), aud = "appstoreconnect-v1", ES256.
Regenerate per script run; don't cache.
- Empty draft review submissions created during testing can't be deleted via API (403).
Ignore them or remove in the UI.
- Replacing screenshots = DELETE then upload (the API appends). To swap a bad set, first
GET /v1/appScreenshotSets/{setid}/appScreenshots, DELETE /v1/appScreenshots/{id} each,
then run the 3-step upload. Otherwise you end up with 6 screenshots (3 stale + 3 new).
- Resubmitting a REJECTED version →
STATE_ERROR.ITEM_PART_OF_ANOTHER_SUBMISSION. The
rejected reviewSubmission still "holds" the version. Free it with
PATCH /v1/reviewSubmissions/{id} {"canceled": true}, then create a fresh submission, add
the version as a reviewSubmissionItem, and PATCH submitted=true. A stray empty
submission left over from a failed attempt may 409 on cancel — just reuse it (add the
item + submit it) instead of creating another.
- Attach a reviewer screen recording via the API (works even while
WAITING_FOR_REVIEW):
3-step like screenshots — POST /v1/appStoreReviewAttachments (attrs fileName+fileSize,
relationship → appStoreReviewDetails/{id}) → PUT bytes to uploadOperations → PATCH
uploaded=true + sourceFileChecksum (MD5). Poll assetDeliveryState.state == COMPLETE.
releaseType: AFTER_APPROVAL on the version means approval auto-publishes it — no
manual "Release" click needed. Confirm via GET appStoreVersions/{id} before submitting.
- Build must be
processingState == VALID before attach-build; list with
GET /v1/builds?filter[app]={aid}&sort=-uploadedDate. Processing takes ~5–15 min after altool.
Screenshot display types (common)
| Device | screenshotDisplayType | Required size (px) |
|---|
| iPad 13" / 12.9" | APP_IPAD_PRO_3GEN_129 | 2064×2752 or 2048×2732 (portrait) |
| iPhone 6.9" | APP_IPHONE_67 | 1290×2796 |
| iPhone 6.5" (legacy, the quirk) | APP_IPHONE_65 | 1242×2688 or 1284×2778 |
Only the first 3 screenshots per set appear on the install sheet.
Per-project template
Fill these per app — keep credentials/URLs/contact in the project's gitignored .env and a
short note in the repo (signing identity + the marketing copy). Replace every <PLACEHOLDER>.
App name: <APP_NAME> (App Store display name, if different)
App ID (ASC): <APP_ID> # numeric ASC App ID
Bundle ID: <BUNDLE_ID> # reverse-DNS, e.g. com.yourorg.app
iCloud container: <ICLOUD_CONTAINER or "none">
Team ID: <TEAM_ID>
Platform: iOS, SwiftUI (universal / iPhone-only / iPad-only)
Category: <APP_STORE_CATEGORY>
Price: <PRICE>
Version / Build: 1.0 / 1 # bump CFBundleVersion on every upload
Backend: <YOUR_API_BASE_URL> # if the app talks to a backend
Marketing site: <MARKETING_URL> Support: <SUPPORT_URL>
Privacy: <PRIVACY_POLICY_URL> Delete account: <DELETE_ACCOUNT_URL>
Project-specific notes (fill in for your app):
- Build system: plain
.xcodeproj, or optionally generated by XcodeGen from project.yml
(xcodegen generate → <YourApp>.xcodeproj, scheme/target <YourApp>). If using XcodeGen,
edit project.yml, never the .pbxproj directly — it is regenerated.
- For a universal app (iPhone + iPad) you need iPhone screenshot sets
APP_IPHONE_67
(1290×2796) and APP_IPHONE_65 (1242×2688), plus iPad APP_IPAD_PRO_3GEN_129. The iPad
build must launch without crashing (see lessons below).
- CloudKit: only relevant if your app uses CloudKit/SwiftData+CloudKit. If data is served
from a backend REST API, skip the "CloudKit Production schema deploy" step entirely.
- If your app has account creation + login, an in-app Delete Account flow is
mandatory (see lessons).
- Manual signing: identity
<DISTRIBUTION_IDENTITY> + profile <PROVISIONING_PROFILE>.
ASC automation key: Key ID <ASC_KEY_ID>, Issuer <ASC_ISSUER_ID>,
p8 at ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8.
- Demo/review account:
<REVIEW_ACCOUNT_EMAIL> / <REVIEW_ACCOUNT_PASSWORD> — must exist
and log in on the live backend before every submission.
- App Privacy: declare the data your app actually collects (e.g. account email/name,
user-generated content) and whether it is used for tracking; review against real backend behavior.
Marketing copy to paste into the version localization (subtitle ≤30 chars, keywords ≤100
chars CSV, promo text ≤170 chars, description ≤4000 chars):
Subtitle: <SUBTITLE, ≤30 chars>
Keywords: <comma,separated,keywords ≤100 chars total>
Promo text: <PROMO TEXT, ≤170 chars>
Description: <DESCRIPTION, ≤4000 chars — explain what the app does and its main features>
Lessons learned / rejection checklist (field-tested)
These items each map to a real App Review rejection on a shipping app. They are written
generically — they apply to any app with the matching characteristics. Run this checklist
before every submit.
Guideline 2.3.3 — Accurate Metadata (screenshots)
A submission was rejected with "the 6.5-inch iPhone screenshots do not show the current
version of the app in use."
Guideline 5.1.1(v) — Data Collection and Storage (account deletion)
A submission was rejected with "the app supports account creation but does not include an
option to initiate account deletion." Any app with login/registration must ship account deletion.
Guideline 2.1 — App Completeness (demo account)
An earlier submission was rejected because the demo review account did not exist on the
live backend / the app crashed on the reviewer's device.
Resubmission recipe — clearing "screenshots + account-deletion" (2.3.3 + 5.1.1(v))
The full end-to-end fix, in order. Reuse this for any "screenshots + account-deletion" rejection.
1. Real screenshots from the Simulator.
- Build + run for the simulator:
xcodebuild ... -sdk iphonesimulator -destination 'platform=iOS Simulator,name=<Simulator Device>', then xcrun simctl install booted <App>.app
xcrun simctl launch booted <BUNDLE_ID>.
- Capture:
xcrun simctl io booted screenshot out.png (a 6.9" Pro Max renders 1320×2868).
- Drive between tabs/screens with
cliclick using the Simulator window geometry
(osascript ... get {position, size} of window 1). Map screen-fraction → window point and
allow ~28 pt for the title bar (bottom-of-screen tab taps are insensitive to it; mid-screen
taps are not). After each shell call the Simulator can lose focus — activate + one throwaway
click before the real tap.
- Resize to the exact slot size with
sips -z <h> <w> in.png --out out.png
(e.g. 6.5" = 1284×2778).
- Upload by deleting the old set first, then the 3-step reserve/PUT/PATCH (see Gotchas).
2. In-app account deletion (the 5.1.1(v) fix).
- Backend: add an authenticated
DELETE /account (or equivalent) that deactivates +
anonymizes — set isActive=false, rewrite the email to a tombstone
(deleted+<id>@…), and null out passwordHash/name/phone/avatar/OAuth ids. Keep the row
(don't hard-delete) so legally-required transaction records stay linkable. The login route
must already reject inactive accounts so the deleted user cannot sign back in.
- App: a clearly-labelled destructive Delete Account button on the Profile screen →
confirmationDialog → call the endpoint → signOut(). Show progress + error states.
- Verify the endpoint is actually LIVE before submitting: register a throwaway account via
the API, call the delete route with its token (expect 200), then try to log in again (expect
401). Don't trust "deploy finished" —
curl the real route.
3. ⚠️ Deploying the backend can expose LATENT crashes. Adding a new endpoint may force the
first rebuild of the API container in months, which compiles the current source and surfaces
bugs that were committed but never deployed (e.g. a stray top-level route handler registered
outside its plugin → ReferenceError crash-loop; or ESM ERR_MODULE_NOT_FOUND from extensionless
relative imports). Symptoms: container exited:unhealthy, "Stopped after reaching restart limit",
site 503 — while the build shows green "Success" (build success ≠ runtime success). To
diagnose, reproduce the container's exact start command locally (read the start command from
your Dockerfile/process config) and read the runtime logs, not the build log. Keep the hotfix
minimal; verify the route is live before resubmitting.
4. The reviewer screen recording (do everything but the typing).
- Synthetic keystrokes do NOT enter text into SwiftUI
TextFields — cliclick t: and
System Events keystroke both silently fail to focus/fill the field. Two reliable options:
(a) have a human type the credentials while you drive everything else, or (b) inject a
pre-authenticated session. Pre-create a simple, easy-to-type throwaway account
(<TEST_ACCOUNT> / short password) so whoever types it isn't fighting a long string — and so
the real demo account is never deleted in the recording.
- Record:
xcrun simctl io booted recordVideo --codec=h264 --force out.mp4 (runs until SIGINT;
stop with pkill -INT -f "simctl io booted recordVideo" so the file finalizes).
- Trim with
ffmpeg -ss <start> -i out.mp4 -c:v libx264 -crf 23 -pix_fmt yuv420p clip.mp4;
sanity-check with a tile=8x4 contact sheet (remember tile only covers fps×tiles seconds).
- Attach via the
appStoreReviewAttachments API (works while WAITING_FOR_REVIEW).
5. Submit + auto-publish. Cancel the old rejected reviewSubmission, add the version to a
fresh one, PATCH submitted=true. With releaseType=AFTER_APPROVAL, approval publishes it
automatically — no further action.