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.

  • Mobile & Desktop
  • $ios-app-store-release
  • 183 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated
  • Editor's pick

Install

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.

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

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.

~/.agents/skills/ios-app-store-release/SKILL.md

--- name: ios-app-store-release description: Takes an iOS app from archive to App Store - certificates and provisioning with automatic or manual signing, App Store Connect API keys, xcodebuild… license: MIT ---

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

SituationChoose
Single developer or small team, Xcode Cloud, or local releasesAutomatic signing (CODE_SIGN_STYLE = Automatic) with -allowProvisioningUpdates
CI with several apps/extensions and shared certificatesManual signing, profiles managed by fastlane match or an equivalent store
Enterprise / ad hoc distributionSeparate 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 .entitlements file; 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 or agvtool next-version -all (requires VERSIONING_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 = NO if 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

GroupLimitReviewUse for
Internalup to 100 App Store Connect usersnoneTeam smoke tests within minutes of processing
Externalup to 10,000 by email or public linkBeta App Review for the first build of a versionCustomers, 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 areaWhat gets rejectedPrevent it
2.1 App completenessCrashes, placeholder content, broken links, login reviewers cannot passDemo account with sample data in review notes; test on a clean install
2.3 Accurate metadataScreenshots not showing the app in use, keyword stuffingReal UI screenshots for each required device size
3.1.1 In-app purchaseDigital goods sold outside IAP where not permittedFollow current storefront rules; see $in-app-purchases
4.8 Login servicesThird-party social login without a privacy-focused alternativeOffer Sign in with Apple or an equivalent option
5.1.1 Data collectionPermission prompts without clear purpose; forced sign-in for non-account featuresExplain benefit in usage strings; allow guest use where possible
5.1.1(v) Account deletionAccount creation without in-app deletionProvide in-app account deletion that actually deletes
4.2 Minimum functionalityThin web wrappersNative 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 = YES leftovers 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 uploadSymbols on and archive the .xcarchive for 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.yamlgenerated from this page

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.py118 lines, 5.3 KB
#!/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.

esc