App store submission
Claude Code skills: auto-detect & start any app, README generator, Docker Hub push, LinkedIn posts, NotebookLM slides, secret scanner, mobile responsiveness testing | Install with npx skills add
npx -y skills add alfredang/skills --skill app-store-submissionAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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).
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
21.7 KB, as published. Nobody here has run it
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 noappDataUsagesrelationship; 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>.p8thenchmod 600it. - 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 pointASC_PRIVATE_KEY_PATHat the.p8. See .env.example. The.p8lives outside the repo and is never committed (.gitignoreexcludes.envand*.p8).
# .env (gitignored)
ASC_KEY_ID=<ASC_KEY_ID>
ASC_ISSUER_ID=<ASC_ISSUER_ID> # xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
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) andCFBundleVersion(build, integer, bump on every upload).ITSAppUsesNonExemptEncryption = falsein 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 legacyarmv7).PrivacyInfo.xcprivacyprivacy 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 editproject.ymlinstead of the.pbxproj. This is entirely optional — a hand-managed.xcodeprojworks the same way for everything below.
# xcodegen generate # only if you use XcodeGen (produces <YourApp>.xcodeproj)
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 # method: app-store
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: <Your Name>") 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 # app id, version, build, blockers
python3 scripts/asc_submit.py set-metadata # copyright, privacyPolicyUrl, URLs
python3 scripts/asc_submit.py review-contact # App Review contact (required)
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 # create review submission + 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_65screenshot even when the binary isUIDeviceFamily=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 anAPP_IPHONE_65set — 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 stillVALID, 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/appScreenshotsreserve withfileSize+fileName→ returnsuploadOperations; (2) PUT the bytes to each operation'surlwith itsrequestHeaders; (3)PATCH /v1/appScreenshots/{id}uploaded=true+sourceFileChecksum= MD5 hex of the file. Then pollassetDeliveryState.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 rejectedreviewSubmissionstill "holds" the version. Free it withPATCH /v1/reviewSubmissions/{id}{"canceled": true}, then create a fresh submission, add the version as areviewSubmissionItem, andPATCH 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(attrsfileName+fileSize, relationship →appStoreReviewDetails/{id}) → PUT bytes touploadOperations→PATCHuploaded=true+sourceFileChecksum(MD5). PollassetDeliveryState.state == COMPLETE. releaseType: AFTER_APPROVALon the version means approval auto-publishes it — no manual "Release" click needed. Confirm viaGET appStoreVersions/{id}before submitting.- Build must be
processingState == VALIDbeforeattach-build; list withGET /v1/builds?filter[app]={aid}&sort=-uploadedDate. Processing takes ~5–15 min afteraltool.
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 fromproject.yml(xcodegen generate→<YourApp>.xcodeproj, scheme/target<YourApp>). If using XcodeGen, editproject.yml, never the.pbxprojdirectly — it is regenerated.- For a universal app (iPhone + iPad) you need iPhone screenshot sets
APP_IPHONE_67(1290×2796) andAPP_IPHONE_65(1242×2688), plus iPadAPP_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."
- Every App Store screenshot is a real capture of the actual current app's working screens (your home / list / detail / main-feature views), taken from the simulator or a device.
- Never reuse another store's assets (e.g. Google Play graphics), marketing mockups, or promotional graphics as screenshots — materials that don't reflect the real app UI are not acceptable.
- No splash screens, no login screens, and no marketing-only graphics in the screenshot set — Apple does not count these as "the app in use."
- The majority of screenshots show the app's main features/functionality.
- Re-capture for every display size you upload (
APP_IPHONE_67,APP_IPHONE_65,APP_IPAD_PRO_3GEN_129) — don't let a stale set ship.
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.
- Ship a working in-app Delete Account flow (e.g. Profile → confirmation → backend
DELETErequest → sign out) before submitting. - Temporary deactivate/disable is not sufficient; it must actually delete the account.
- If a website is needed to finish deletion, deep-link directly to your
<DELETE_ACCOUNT_URL>(not just the homepage). Only highly-regulated apps may require email/phone/customer-service to delete — most apps don't qualify. - Attach a screen recording of the deletion flow in the App Review Notes.
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.
- Verify
<REVIEW_ACCOUNT_EMAIL>/<REVIEW_ACCOUNT_PASSWORD>actually logs in against your live backend right before submitting (don't assume). - Confirm a demo/TestFlight build launches without crashing on every device family you support — for a universal app, reviewers test on iPad too.
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>', thenxcrun simctl install booted <App>.appxcrun 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
cliclickusing 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 — setisActive=false, rewrite the email to a tombstone (deleted+<id>@…), and null outpasswordHash/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" —
curlthe 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 Eventskeystrokeboth 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 withpkill -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 atile=8x4contact sheet (remembertileonly coversfps×tilesseconds). - Attach via the
appStoreReviewAttachmentsAPI (works whileWAITING_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.