iOS App Store Release skill for Codex
From archive to phased release, no surprises. Codex archives and exports with explicit ExportOptions and API key auth, then runs a bundled preflight script on the built Info.plist and privacy manifest before anything is uploaded. It routes builds through internal and external TestFlight, checks the app against the review guidelines that most often reject apps, and releases with phased rollout, pausing at every upload and submission step for your confirmation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ios-app-store-release Installs to ~/.agents/skills/ios-app-store-release/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ios-app-store-release The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ios-app-store-release Installs to .agents/skills/ios-app-store-release/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ios-app-store-release.zipunzip -o ios-app-store-release.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ios-app-store-release PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ios-app-store-release, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Takes an iOS app from archive to App Store - certificates and provisioning with automatic or manual signing, App Store Connect API keys, xcodebuild archive and exportArchive with ExportOptions.plist, Info.plist and privacy manifest preflight, TestFlight internal and external testing, App Review guideline traps, phased release and expedited fixes. Use when signing fails, preparing a TestFlight build, a build is stuck in processing or rejected at upload, answering an App Review rejection, or planning a production release.
Use it when
- Preparing a TestFlight or App Store build from Xcode or the command line
- Signing, provisioning or export errors block an archive
- An upload is rejected by App Store Connect validation
- Responding to an App Review rejection or planning a phased release
Use something else when
- You want fastlane lanes and certificate sync on CI: use mobile-ci-fastlane
- The app is built with Expo and EAS: use expo-eas-builds
- The target is a Mac app needing notarization: use macos-appkit
How to check the result
preflight_plist.py exits 0 on the exported ipa, the build appears in TestFlight without an Invalid Binary email, and internal testers install it on the oldest and newest supported iOS.
The mistake it prevents
Forgetting a separate provisioning profile and matching entitlements for an app extension, which only fails at export time after a long archive.
Done well means: the archive is signed reproducibly (not “it works on my Mac”), the build passes upload validation the first time, testers get it through TestFlight before users do, review sees a complete app with working demo access, and production rolls out in phases with someone watching crashes.
Release gates
Uploading, submitting for review and releasing are outward-facing actions under the user’s developer account. Prepare everything, then ask for explicit confirmation before: uploading a build, submitting for review, releasing to users, or changing phased release.
Inspect first
cat AGENTS.md 2>/dev/null
xcodebuild -version # uploads need Xcode 26+ (iOS 26 SDK) since 28 Apr 2026
xcodebuild -list -project App.xcodeproj
xcodebuild -showBuildSettings -scheme App -configuration Release | \
grep -E 'PRODUCT_BUNDLE_IDENTIFIER|MARKETING_VERSION|CURRENT_PROJECT_VERSION|CODE_SIGN_STYLE|DEVELOPMENT_TEAM|PROVISIONING_PROFILE'
security find-identity -v -p codesigning # installed signing identities
ls fastlane/ 2>/dev/null # existing automation: use mobile-ci-fastlane
The minimum Xcode/SDK for uploads rises every spring; check Apple’s upcoming requirements page when a new year’s cycle starts. The deployment target can stay lower.
Signing
| Situation | Choose |
|---|---|
| Single developer or small team, Xcode Cloud, or local releases | Automatic signing (CODE_SIGN_STYLE = Automatic) with -allowProvisioningUpdates |
| CI with several apps/extensions and shared certificates | Manual signing, profiles managed by fastlane match or an equivalent store |
| Enterprise / ad hoc distribution | Separate profiles and export method; never mix with App Store profiles |
- Each target (app, widget, notification service extension, watch app) needs its own App ID and profile with matching entitlements. A missing extension profile is the most common export failure.
- Capabilities (push, App Groups, Associated Domains, iCloud) must be enabled on the App
ID and present in the
.entitlementsfile; mismatches fail at export or upload. - Use an App Store Connect API key (
.p8, key ID, issuer ID) for CLI and CI auth. Store it in the CI secret store; never commit it or print it.
Version and build numbers
MARKETING_VERSION(CFBundleShortVersionString): what users see,2.4.0.CURRENT_PROJECT_VERSION(CFBundleVersion): must increase for every upload of the same version. Use CI run number oragvtool next-version -all(requiresVERSIONING_SYSTEM = apple-generic).
Archive and upload
xcodebuild archive \
-scheme App -configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/App.xcarchive \
-allowProvisioningUpdates \
-authenticationKeyPath "$ASC_KEY_PATH" \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
ExportOptions.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string> <!-- "upload" sends straight to App Store Connect -->
<key>teamID</key><string>ABCDE12345</string>
<key>signingStyle</key><string>automatic</string>
<key>uploadSymbols</key><true/>
<key>manageAppVersionAndBuildNumber</key><false/>
</dict>
</plist>
xcodebuild -exportArchive -archivePath build/App.xcarchive \
-exportOptionsPlist ExportOptions.plist -exportPath build/export \
-allowProvisioningUpdates -authenticationKeyPath "$ASC_KEY_PATH" \
-authenticationKeyID "$ASC_KEY_ID" -authenticationKeyIssuerID "$ASC_ISSUER_ID"
Export first, run the preflight below, then upload: after confirmation, rerun the export
with destination set to upload, or use the Transporter app. Older Xcode used method
app-store; current Xcode names it app-store-connect.
Archiving needs macOS with Xcode; uploading needs network. In a sandbox, prepare the commands and ask the user to run or approve them.
Preflight
python3 scripts/preflight_plist.py build/export/App.ipa --previous-build 311 --expect-version 2.4.0
The script reads the built Info.plist and checks: bundle ID resolved, version and build
format, build number greater than the last upload, export-compliance key present, usage
description strings not empty or placeholders, arbitrary-loads ATS exceptions, and a
PrivacyInfo.xcprivacy in the app bundle. It exits 1 on errors. Also check by hand:
- Privacy manifest: declares required-reason APIs your code and SDKs use
(
UserDefaults, file timestamps, system boot time, disk space). Third-party SDKs on Apple’s list must ship their own signed manifests. ITSAppUsesNonExemptEncryption = NOif the app only uses OS-provided HTTPS/crypto.- App Privacy answers in App Store Connect match what the app and its SDKs collect.
- Age rating questionnaire answered in its current form.
TestFlight
| Group | Limit | Review | Use for |
|---|---|---|---|
| Internal | up to 100 App Store Connect users | none | Team smoke tests within minutes of processing |
| External | up to 10,000 by email or public link | Beta App Review for the first build of a version | Customers, beta programme |
Builds expire after 90 days. Provide “What to Test” notes per build. Processing usually takes minutes; if a build disappears or shows “Invalid Binary”, check the email from App Store Connect for the exact ITMS error.
App Review traps
| Guideline area | What gets rejected | Prevent it |
|---|---|---|
| 2.1 App completeness | Crashes, placeholder content, broken links, login reviewers cannot pass | Demo account with sample data in review notes; test on a clean install |
| 2.3 Accurate metadata | Screenshots not showing the app in use, keyword stuffing | Real UI screenshots for each required device size |
| 3.1.1 In-app purchase | Digital goods sold outside IAP where not permitted | Follow current storefront rules; see $in-app-purchases |
| 4.8 Login services | Third-party social login without a privacy-focused alternative | Offer Sign in with Apple or an equivalent option |
| 5.1.1 Data collection | Permission prompts without clear purpose; forced sign-in for non-account features | Explain benefit in usage strings; allow guest use where possible |
| 5.1.1(v) Account deletion | Account creation without in-app deletion | Provide in-app account deletion that actually deletes |
| 4.2 Minimum functionality | Thin web wrappers | Native value beyond the website |
Responding to a rejection: quote the guideline, fix the issue or explain precisely in Resolution Center why the app complies, and attach a screen recording when behaviour is the question. Draft the reply for the user; do not send it without their approval.
Production release
- [ ] Build N passed internal TestFlight on oldest and newest supported iOS
- [ ] Release notes, screenshots and privacy answers updated in App Store Connect
- [ ] Submitted for review (user confirmed)
- [ ] Release mode chosen: manual release after approval is safest
- [ ] Phased release on (7 days: 1%, 2%, 5%, 10%, 20%, 50%, 100%)
- [ ] Crash-free sessions and key metrics watched for 24-48 h at each step
- [ ] Pause phased release if crash rate rises (can pause, total up to 30 days)
Phased release only affects automatic updates; anyone can download the new version manually. For a critical bug after release, ship a fixed build and request an expedited review with a factual explanation; do not rely on it being granted.
Gotchas
- Bitcode is gone; any
ENABLE_BITCODE = YESleftovers or old SDK flags should be removed. - Upload fails with “Missing Purpose String” when a linked SDK references a protected API: the key is needed even if your code never calls it.
- An expired distribution certificate breaks only new builds; shipped apps keep working. Revoking it does not remove apps from sale, but it does invalidate profiles using it.
- Missing dSYMs mean unsymbolicated crash reports; keep
uploadSymbolson and archive the.xcarchivefor each release. - Build numbers are per marketing version in App Store Connect but must still be unique per upload; reusing one fails after a long upload.
Report template
**Build:** App 2.4.0 (312), Xcode 26.1, iOS deployment target 17.0
**Signing:** automatic, team ABCDE12345, API key auth; 3 targets (app, widget, notification ext)
**Preflight:** preflight_plist.py OK; privacy manifest present; encryption exempt
**TestFlight:** internal group "Core" (uploaded after user confirmation)
**Next:** external beta review, then submit 2.4.0 with phased release (awaiting go-ahead) What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "iOS App Store Release"
short_description: "From archive to phased release, no surprises"
default_prompt: "$ios-app-store-release Archive this app and get it onto TestFlight for the internal team" scripts/preflight_plist.py
#!/usr/bin/env python3
"""Pre-upload checks for an iOS build's Info.plist and privacy manifest.
Usage:
python3 preflight_plist.py <App.ipa | App.xcarchive | App.app | Info.plist>
[--previous-build N] [--expect-version X.Y.Z]
Reads the built Info.plist (binary or XML) with plistlib, checks the fields App Store
Connect and App Review most often reject, and exits 1 if any ERROR is found.
Standard library only.
"""
from __future__ import annotations
import argparse
import plistlib
import re
import sys
import zipfile
from pathlib import Path
# Usage-description keys that, when present, must be meaningful.
USAGE_KEYS = [
"NSCameraUsageDescription", "NSMicrophoneUsageDescription",
"NSPhotoLibraryUsageDescription", "NSPhotoLibraryAddUsageDescription",
"NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription",
"NSContactsUsageDescription", "NSCalendarsFullAccessUsageDescription",
"NSCalendarsWriteOnlyAccessUsageDescription", "NSRemindersFullAccessUsageDescription",
"NSBluetoothAlwaysUsageDescription", "NSFaceIDUsageDescription",
"NSMotionUsageDescription", "NSHealthShareUsageDescription",
"NSHealthUpdateUsageDescription", "NSSpeechRecognitionUsageDescription",
"NSUserTrackingUsageDescription", "NSLocalNetworkUsageDescription",
]
PLACEHOLDER = re.compile(r"^(todo|tbd|test|usage|description|\$\(.*\)|lorem.*)$", re.I)
def load(target: Path) -> tuple[dict, bool | None]:
"""Return (info_plist, has_privacy_manifest or None if unknown)."""
if target.suffix == ".ipa":
with zipfile.ZipFile(target) as z:
names = z.namelist()
info = [n for n in names if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", n)]
if not info:
raise SystemExit("ERROR: no Payload/*.app/Info.plist inside the ipa")
app_dir = info[0].rsplit("/", 1)[0] + "/"
has_manifest = f"{app_dir}PrivacyInfo.xcprivacy" in names
return plistlib.loads(z.read(info[0])), has_manifest
if target.suffix == ".xcarchive":
apps = list((target / "Products" / "Applications").glob("*.app"))
if not apps:
raise SystemExit("ERROR: no Products/Applications/*.app in the archive")
target = apps[0]
if target.suffix == ".app":
with open(target / "Info.plist", "rb") as f:
return plistlib.load(f), (target / "PrivacyInfo.xcprivacy").exists()
with open(target, "rb") as f:
return plistlib.load(f), None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("target", type=Path)
ap.add_argument("--previous-build", help="last build number uploaded for this version")
ap.add_argument("--expect-version", help="marketing version you intend to ship")
args = ap.parse_args()
info, has_manifest = load(args.target)
errors: list[str] = []
warnings: list[str] = []
bundle_id = info.get("CFBundleIdentifier", "")
version = str(info.get("CFBundleShortVersionString", ""))
build = str(info.get("CFBundleVersion", ""))
if not bundle_id or "$(" in bundle_id:
errors.append(f"CFBundleIdentifier unresolved: {bundle_id!r}")
if not re.fullmatch(r"\d+(\.\d+){0,2}", version):
errors.append(f"CFBundleShortVersionString must be 1-3 dot-separated integers, got {version!r}")
if not re.fullmatch(r"\d+(\.\d+){0,2}", build):
errors.append(f"CFBundleVersion must be 1-3 dot-separated integers, got {build!r}")
if args.expect_version and version != args.expect_version:
errors.append(f"version is {version}, expected {args.expect_version}")
if args.previous_build and re.fullmatch(r"\d+(\.\d+){0,2}", build):
cur = tuple(int(p) for p in build.split("."))
prev = tuple(int(p) for p in args.previous_build.split("."))
if cur <= prev:
errors.append(f"build {build} is not greater than previous build {args.previous_build}")
if "ITSAppUsesNonExemptEncryption" not in info:
warnings.append("ITSAppUsesNonExemptEncryption missing: every upload will ask the export-compliance question")
for key in USAGE_KEYS:
if key in info:
text = str(info[key]).strip()
if not text or PLACEHOLDER.match(text):
errors.append(f"{key} is empty or a placeholder: {text!r}")
elif len(text) < 25:
warnings.append(f"{key} is very short ({text!r}); App Review expects the user benefit explained")
ats = info.get("NSAppTransportSecurity", {})
if isinstance(ats, dict) and ats.get("NSAllowsArbitraryLoads"):
warnings.append("NSAllowsArbitraryLoads is true: be ready to justify it in review")
if has_manifest is False:
warnings.append("PrivacyInfo.xcprivacy not found in the app bundle (required-reason APIs need one)")
elif has_manifest is None:
warnings.append("privacy manifest not checked (pass the .app, .ipa or .xcarchive to check it)")
print(f"{bundle_id} version {version} ({build}) min OS {info.get('MinimumOSVersion', '?')}")
for w in warnings:
print(f"WARN: {w}")
for e in errors:
print(f"ERROR: {e}")
print("RESULT:", "FAIL" if errors else "OK", f"({len(errors)} errors, {len(warnings)} warnings)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.