<!-- AUTO-GENERATED from apple-release.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
# Apple App Store release

<!-- Ported from time-attack/gstack (GStack 2) APPLE-RELEASE.md, refined
     across 21 live App Store releases. Portions copyright (c) 2026
     Sina Matian, time-attack/gstack, MIT. Adapted to gstack main's
     architecture: decision-store bins, the Third-Party Web Actions
     contract, and an idempotency log in place of the fork's durable-state
     wrapper. All credential examples are placeholders, never live formats. -->

Applies when the ship target is an Apple platform app: the repository contains an `.xcodeproj` or `.xcworkspace`, or a Swift package with an app product. Read this BEFORE Step 1's branch gate and any preflight. This adapter extends ship's judgment to the App Store journey end to end, and every upload or submission is treated as a durable external effect (idempotency contract below) — but store distribution is its own release path, not repository landing: ship's branch/PR ceremony (feature-branch gate, commit-review-PR pipeline, merge queue) applies only when the user asked to land repository changes. A user shipping to the App Store or TestFlight proceeds through this adapter from whatever branch they are on; a clean working tree on the base branch is a normal and valid state to archive and upload from. Never abort an App Store release over branch topology.

One tool runs the entire release: machine-level fastlane — `produce` (app record and bundle ID), `cert` and `sigh` (signing), `gym` (archive and signed export), `pilot` (TestFlight), `deliver` (metadata, screenshots, Submit for Review), `frameit` (device frames). Install it when missing (`brew install fastlane`) with a one-line announcement, not a question — the release authorization covers machine-tool installs. Never install additional App Store CLI tools, and never mention API keys, `.p8` files, sessions, or any credential format to the user DURING the run — the sole exception is the one-line standing-credential disclosure in the closing report (see Storefront completion), which names the durable key the release created so the user can revoke it later; they paid US$99 and want to ship, and the release itself adds no new dependency to the user's project.

A Mac is required only for the build legs. Archiving, signing, and the binary upload ride Xcode's macOS-only toolchain — Apple ships it nowhere else, and no tool routes around that. On a non-macOS host, say so plainly, then route exactly those legs through a macOS CI runner (a GitHub Actions `macos` runner executing the same `gym` and `deliver`/`pilot` commands, with the minted upload key supplied as a CI secret — key auth is precisely what CI wants); sign-in, key minting, metadata, screenshots, pricing, and submission judgment are plain API work that stays on the user's machine. Never claim the whole release is impossible off a Mac, and never pretend the build leg is possible there.

## The one authorization moment

The whole journey permits exactly two interactions, and no others. FIRST, up front: confirm the user holds a paid Apple Developer Program membership (US$99/year — the App Store and TestFlight both require it) and authorize the release. Pricing belongs to this same breath, once per app EVER: ask free or paid (and the price if paid) inside the authorization question — never as a separate interruption — after checking the decision store (`bin/gstack-decision-search --scope repo --query "pricing"`); persist the answer (`~/.claude/skills/gstack/bin/gstack-decision-log`, scope `repo`) so no later release re-asks, and a paid answer names the one-time Paid Apps banking/tax agreement honestly right there, since nothing sells until it is signed. Price is a launch decision the agent never defaults silently: a free launch cannot be un-launched. Apple sign-in happens inside this same moment: run `fastlane spaceauth -u <apple-id>` through the host's interactive command path (in Claude Code, the user types `! fastlane spaceauth -u <email>` so their password and one two-factor code go directly to Apple in-session; a separate terminal window is the fallback only when the host has no interactive path). Keep the printed session token out of the transcript — the cached cookie in `~/.fastlane/spaceship/` is the credential fastlane actually uses; never store, echo, or log the password or token, and re-run the same one command when the session expires. Immediately after the first sign-in, mint the permanent upload key from the session (step 4 of Archive and upload) — when that key already sits at `~/.gstack/apple/api-key.json` and no new app record is needed, skip the sign-in entirely: repeat releases authorize and proceed with zero sign-in. SECOND, only when preflight finds the icon or screenshots missing: the store-assets question below. Everything else — tool installs, upload, storefront, submission — is covered by the authorization and proceeds without asking. Auth menus, tool-choice questions, plan confirmations, and step-by-step narration requests are contract violations.

No membership: STOP the App Store path. Offer to walk enrollment at developer.apple.com through the Third-Party Web Actions contract (earlier in this skill) (a purchase the user completes themselves; activation can take a day or two), and name the free-account ceiling honestly: personal-team installs on the user's own devices only, expiring after 7 days, no TestFlight, no App Store.

## Release preflight

Resolve and verify before archiving. Fix what the printed mutation boundary authorizes; report everything else as a blocking finding.

- Signing: development team on the app target; `cert` and `sigh` mint the distribution certificate and App Store profile when none exist.
- Versioning: a marketing version users should see and a build number strictly greater than any build already uploaded for that version.
- Dependencies: `xcodebuild -resolvePackageDependencies` succeeds; if a `Podfile` or `Cartfile` exists, its install step has been run and lockfiles are current.
- App Store validation blockers: complete app icon set including the 1024pt marketing icon, launch screen, a usage-description string for every privacy-gated API the app touches, required privacy manifests, an export-compliance answer (`ITSAppUsesNonExemptEncryption`), and a sane deployment target.

## Store assets

Only when preflight finds the icon or screenshots missing, ask once — the journey's second and final permitted question — then act on the choice without further prompts. Once per app, EVER: before asking, check the decision store (`bin/gstack-decision-search --scope repo --query "store assets"`); a settled choice (including "defer screenshots" or "TestFlight only") is applied silently, never re-asked. After the user answers, persist it (`~/.claude/skills/gstack/bin/gstack-decision-log` with scope `repo`) so no future run asks again; the user changes it by saying so, not by being re-prompted. Offer:

- **App icon**: SnapAI (`npx snapai`, the app-icon agent skill) generates the single 1024×1024 with the user's own image-generation key; Xcode 15+ derives every size from that one image.
- **Marketing screenshots, free and local, no API key**: the app-store-screenshots deck editor skill — scaffold it, prefill its deck JSON with simulator captures and benefit headlines, and export one bundle covering every required iPhone size (the export is headlessly automatable). Marketing-grade does NOT require an image backend; never claim screenshots need an API key while this skill is installed.
- **Plain frames, free and local**: capture the built app in the simulator and frame with fastlane `frameit` — the minimal option when no designed deck is wanted.
- **AI-enhanced marketing screenshots**: the aso-appstore-screenshots agent skill (benefit headlines, breakout panels, exact App Store dimensions) — the only option that needs the user's own image-generation key; when installed, follow its workflow rather than reimplementing it.
- **User-supplied files**: always a valid answer; validate dimensions and move on.

Build this question's options from a LIVE check of installed skills at ask time — never from memory or an earlier conversation turn. Whenever the app-store-screenshots deck editor skill is installed, its free no-key option MUST appear in the list; omitting it is a contract violation equal to claiming screenshots need an API key. Assets already present skip this entirely. Announce what was generated at exit.

## Archive and upload

1. Archive and export the signed Release build with `gym` (it drives xcodebuild and the signing minted in preflight). Projects with custom archive requirements may drop to `xcodebuild archive` directly; the output either way is an App Store-signed `.ipa`.
2. The upload is an external effect: run `pilot` (TestFlight) or `deliver` (App Store) under the durable-effect contract: before executing, append the key `appstore.upload.<bundle-id>.<build>` to `~/.gstack/projects/$SLUG/apple-effects.log`; if the key is already present from a prior run (crash, retry), treat the upload as possibly-done and do NOT re-run it. Never re-upload on ambiguity; inspect App Store Connect for the build first.
3. The cached session, the minted key, and every credential file are env- or file-level secrets: never argv, never echoed, never committed.
4. NEVER demand an app-specific password — the session MINTS the upload key instead. Per fastlane's documented authentication, Apple's binary-upload tool (iTMSTransporter, which `deliver`/`pilot` shell out to for the `.ipa`) does not accept the web session: it takes only an App Store Connect API key or an app-specific password, and Apple's error `-22938` ("Sign in with the app-specific password") is Transporter saying exactly this. That is not a gate and not a question, because the web session creates the key itself, silently: through fastlane's bundled spaceship (`Spaceship::Tunes.login(<apple-id>)` reuses the cached cookie, then raw client requests), `POST https://appstoreconnect.apple.com/iris/v1/apiKeys` with a JSON:API body SCOPED to the app being released, not all apps: `{data:{type:"apiKeys",attributes:{nickname:"gstack-upload",allAppsVisible:false,roles:["APP_MANAGER"],keyType:"PUBLIC_API"},relationships:{apps:{data:[{type:"apps",id:"<asc-app-id>"}]}}}}`, where `<asc-app-id>` is the App Store Connect app id (from `produce`'s output, or `GET https://appstoreconnect.apple.com/iris/v1/apps?filter[bundleId]=<bundle-id>`). `allAppsVisible:false` with an explicit `apps` relationship is least-privilege on purpose — an `allAppsVisible:true` APP_MANAGER key is standing authority over every app on the team, a needless blast radius if the machine is later compromised. The `apps` relationship is REQUIRED, not optional: a key with no app association can see nothing and uploads fail with a permissions error, so scope it to the target app rather than flipping the flag alone. Mint it only after the app record exists (so `produce` runs first when the app is new). Then `GET .../iris/v1/apiKeys/<id>?fields[apiKeys]=privateKey` — the `privateKey` attribute is base64 of the COMPLETE PEM file: decode it exactly once and write `~/.appstoreconnect/private_keys/AuthKey_<id>.p8` (0600) immediately, it is downloadable only at creation. The issuer ID is `provider.publicProviderId` from `GET https://appstoreconnect.apple.com/olympus/v1/session`. Record key id, issuer id, and key content as a fastlane api-key JSON at `~/.gstack/apple/api-key.json` (0600) and run `deliver`/`pilot` with `api_key_path` from then on. The key never expires, so every later release of the SAME app skips sign-in; releasing a DIFFERENT app re-associates that app onto the key (`PATCH .../iris/v1/apiKeys/<id>` adding it to the `apps` relationship) or mints a fresh app-scoped key, because the key is deliberately not all-apps. The session stays necessary only for `produce` (Apple's public API cannot create app records), for that re-association, and for re-minting if the key is ever revoked. Stating that the user must generate any credential themselves while key minting is untried is a contract violation. CLASSIFY the error before touching credentials: an error is an authentication failure ONLY when it says so (401/403, session invalid or expired, "sign in", "app-specific password" in Apple's own words). A `Spaceship::UnexpectedResponse`, missing/invalid attribute, validation, or precheck error is a METADATA problem — fix the payload (for example, Apple's expanded age-rating attributes such as `lootBox`, `ageAssurance`, `parentalControls`, `messagingAndChat` in `app_rating_config.json`) and retry from the CLI. Treating a metadata error as a credential problem is a contract violation.
5. Within an Apple release, this adapter OVERRIDES the Third-Party Web Actions contract (earlier in this skill): the general agentic-browser offer never applies to App Store Connect, Apple ID, or credential work here. The entire release is CLI (fastlane) plus the two permitted interactions; the ONLY browser use this adapter allows, ever, is the paid-app agreements/banking/tax residue named at the end of this document. Opening a browser — driven or manual — for anything else in this journey is a contract violation. When a real error does force the fallback, QUOTE the error verbatim, then escalate in this order: FIRST mint (or re-mint) the upload key from the session per step 4 and retry the upload with `api_key_path` — an upload-auth error with no key on disk means the mint was skipped, not that the user owes a credential. SECOND, if the minting itself fails with a session error, ask the user to sign in again (the same `! fastlane spaceauth -u <apple-id>` moment as the original authorization), re-mint, and retry. Only when a FRESH session still cannot mint a key — a permissions refusal because the signed-in Apple ID is not Admin or Account Holder on its team — does the app-specific-password path open, and its only shape is self-service: the user generates the password on any device and enters it through the host's in-session masked prompt into the macOS keychain (`fastlane fastlane-credentials add --username <apple-id>`), then the upload is retried. NEVER offer or recommend a browser drive to create credentials — no agentic browser of any kind, for any password, key, or token, under any framing.
6. App Review contact details (name, email, phone) are required metadata for submission: infer name and email from the signed-in Apple ID and git config, collect the phone number once inside the authorization moment, persist it to the decision store, and never re-ask. Contact details are metadata, not a blocking gate to announce mid-run.

## Storefront completion

`produce` already created the app record and bundle ID during the run — never call the app record a manual gate. Apply the pricing settled in the authorization moment through the App Store Connect price-schedule endpoint (`POST /v1/appPriceSchedules` via the session or the minted key): fastlane's `price_tier` option is broken against the current API ("'prices' is not a relationship on 'apps'"), so never route pricing through it or call its failure an account problem. `deliver` owns everything else the store listing needs: description, keywords, localizations, screenshot upload per device size, attaching the uploaded build, and Submit for Review; `pilot` manages TestFlight groups and testers as an intermediate round when the user asked for one. Submission follows the same durable-effect contract with key `appstore.submit.<bundle-id>.<version>` — on ambiguity, inspect App Store Connect before re-running. Monitor review status from the CLI afterward.

What remains web-only, ever: the paid Apple Developer Program membership purchase itself (a precondition, not a release step) and, for PAID apps only, the one-time Paid Apps agreement with banking and tax — offer the agentic-browser drive per the Third-Party Web Actions contract (earlier in this skill) before any manual checklist for those. A free app needs no browser at any point. After submission, report that App Review typically answers within a day or two and close the run; review outcome is not a gate this workflow can hold open. In that SAME closing report, disclose the durable credential the release created — one line, once per run: "This created an App Store Connect API key (`gstack-upload`, scoped to this app) that persists for future releases; revoke it anytime at App Store Connect → Users and Access → Integrations, or delete `~/.gstack/apple/api-key.json` locally." This is the deliberate exception to the mid-run no-credential-talk rule (line 14): the user is otherwise never told a standing credential now exists on their account and on disk, so it never reaches their revocation checklist. Disclosure at exit, not a mid-run question, so the one-authorization-moment contract holds.
