| name | ns-ios-distribute |
| description | Prepare and submit NowStack Mobile iOS App Store releases. Use for real 6.5-inch app screenshots, metadata, IAP, review details, validation, and submit. |
iOS Distribute - NowStack Mobile
Take a TestFlight-ready app all the way to App Store review submission: store screenshots, metadata, IAP pricing/availability, age rating, review details with demo account, validation, and submit. If no build is on TestFlight yet, run ns-ios-testflight first.
Orchestrate the full App Store distribution after a build is on TestFlight. This is the proven end-to-end flow (battle-tested on a real app built from this boilerplate, reaching zero `asc validate` errors): real app screenshots → store version + metadata → IAP readiness → compliance → review details → validate → submit.
Everything runs with repo tools and public CLIs only:
mobile-app/scripts/asc-api.mjs for all App Store Connect API calls (ASC_KEY_ID/ASC_ISSUER_ID/ASC_P8_PATH env vars).
asc CLI for uploads (screenshots, subscription assets) and validation.
mobile-app/scripts/store-screenshots.mjs + Maestro + xcrun simctl for captures.
Sequencing with the rest of the lifecycle:
- No processed build yet → run the
ns-ios-testflight skill first.
- Screenshot capture details →
.agents/skills/ns-generate-store-screenshots/SKILL.md.
- This file — version, metadata, IAP, compliance, review, submit.
For deep platform specifics use
.agents/skills/ns-ios-deploy-app/SKILL.md / .agents/skills/ns-deploy-android-app/SKILL.md.
<state_variables>
| Variable | Source |
|---|
{asc_app_id} | GET /v1/apps?filter[bundleId]={bundle_id} |
{version_id} | the appStoreVersions id in PREPARE_FOR_SUBMISSION |
{version_loc_id} | en-US appStoreVersionLocalizations id |
{app_info_id} / {app_info_loc_id} | from GET /v1/apps/{asc_app_id}/appInfos (+ its localizations) |
{build_db_id} | processed build id from GET /v1/builds?filter[app]={asc_app_id} |
{subscription_id} / {sub_group_id} | if the app sells a subscription |
{screenshots_dir} | dated output of the screenshot run, e.g. documents/store-screenshots/<date>/ios/iphone-65/en-US |
| </state_variables> | |
<critical_safety>
- Every mutation against App Store Connect is externally visible. Confirm with the user before: uploading screenshots, changing pricing/availability, and ABOVE ALL the final review submission.
- Never print or commit ASC credentials.
documents/store-screenshots/ is gitignored — keep it that way.
- Screenshots must contain only demo data (the seeded demo account), never real user content.
- Screenshots must be raw captures of the real app UI. Do not create screenshot-only routes, fake marketing screens, fake text overlays, fake cards, fake product claims, or composite promo images unless the user explicitly asks for framed marketing assets.
- If a previous agent created
/store/*, store/[shot], or any other fake screenshot route, stop and remove/ignore it before capturing or uploading App Store screenshots.
- Metadata values (URLs, support email, price) come from
site-config.ts — do not invent them.
</critical_safety>
<asc_entity_map>
App Store Connect data model — knowing which entity owns which field saves hours:
| Field | Entity | Endpoint |
|---|
| description, keywords, promotionalText, supportUrl, marketingUrl | appStoreVersionLocalizations | PATCH /v1/appStoreVersionLocalizations/{id} |
| subtitle, privacyPolicyUrl | appInfoLocalizations | PATCH /v1/appInfoLocalizations/{id} |
| primaryCategory | appInfos | PATCH /v1/appInfos/{id} (relationships) |
| copyright, release type | appStoreVersions | PATCH /v1/appStoreVersions/{id} |
| attached build | appStoreVersions relationship | PATCH /v1/appStoreVersions/{id}/relationships/build |
| age rating answers | ageRatingDeclarations | PATCH /v1/ageRatingDeclarations/{id} |
| contact + demo account | appStoreReviewDetails | POST /v1/appStoreReviewDetails |
| content rights | apps | asc apps update --id {asc_app_id} --content-rights ... |
| </asc_entity_map> | | |
```bash
export ASC_KEY_ID=... ASC_ISSUER_ID=... ASC_P8_PATH=...
asc auth status --validate
node mobile-app/scripts/asc-api.mjs GET "/v1/apps?filter[bundleId]={bundle_id}" # -> {asc_app_id}
node mobile-app/scripts/asc-api.mjs GET "/v1/builds?filter[app]={asc_app_id}&limit=3"
```
- No processed build → run the
ns-ios-testflight skill first.
- Resolve
{version_id}: GET /v1/apps/{asc_app_id}/appStoreVersions?filter[appStoreState]=PREPARE_FOR_SUBMISSION. If none exists, create it: POST /v1/appStoreVersions with {platform: "IOS", versionString: "<version>"} + app relationship.
- Read
site-config.ts: urls (website/privacy/terms), support email, payment product IDs, price.
- The web app must be LIVE (privacy + terms URLs are review gates) — if not, stop and point to
docs/production-checklist.md stage 2.
Follow `.agents/skills/ns-generate-store-screenshots/SKILL.md` for the full workflow, but the rules in this phase override it whenever there is a conflict about device size, dimensions, screenshot routes, or generated marketing/composite screens. The proven recipe:
- Real app UI only: capture the application screens as a user would see them: onboarding, auth, signed-in home/dashboard, real feature screen, real paywall, and real settings/account/privacy screen. Demo data is allowed; fake screenshot-only pages, fake cards, marketing layouts, and invented overlay copy are not allowed.
- Demo data/session: create/verify a seed function (e.g.
convex/seedStoreDemo.ts, app-specific) so real screens show clean demo content. Sign in with the built-in review account appstoretest@email.com / OTP 123456 for auth-gated screens. Never use real personal data.
- Paywall capture: temporarily set
EXPO_PUBLIC_ALLOW_DEV_PAYMENT_BYPASS=false so the real paywall renders (restore afterwards). The paywall screenshot must be the actual in-app paywall, not a recreated promo screen.
- Device: always use the App Store "iPhone 6.5 Display" slot: iPhone 11 Pro Max / XS Max class simulator → native 1242x2688 = Apple display type
IPHONE_65. Do not use iPhone 14 Plus / iPhone 15 Pro Max / 6.7-inch screenshots unless the user explicitly asks for another slot.
xcrun simctl create "store-iphone-65" "com.apple.CoreSimulator.SimDeviceType.iPhone-11-Pro-Max" <runtime>
cd mobile-app && npm run screenshots:store:ios
npm run screenshots:store:ios -- --execute
- Maestro flows live in
mobile-app/maestro/store/ (Maestro needs JDK 17: export JAVA_HOME=/opt/homebrew/opt/openjdk@17). Robust flow patterns: extendedWaitUntil: {visible: "<text>", timeout: 10000} before every tap; conditional paywall dismissal with runFlow: when: visible:.
- Validate:
sips -g pixelWidth -g pixelHeight *.png — every file must be exactly 1242x2688. Then visual QA each PNG (read the images): real app screen, no fake route, no dev overlays, no status-bar clutter, no placeholder copy, no invented claims.
```bash
# attach the processed build to the version
node -e "require('fs').writeFileSync('/tmp/build-rel.json',JSON.stringify({data:{type:'builds',id:'{build_db_id}'}}))"
node mobile-app/scripts/asc-api.mjs PATCH /v1/appStoreVersions/{version_id}/relationships/build /tmp/build-rel.json
```
Write metadata (draft it from docs/templates/app-store-metadata.example.json + site-config.ts, show the user for approval first):
PATCH /v1/appStoreVersionLocalizations/{version_loc_id} — description (append Terms of Use: <terms-url> and Privacy Policy: <privacy-url> at the end — Apple requires visible terms for subscription apps), keywords (100 chars max, comma-separated, no spaces after commas), promotionalText, supportUrl, marketingUrl.
PATCH /v1/appInfoLocalizations/{app_info_loc_id} — subtitle (30 chars max), privacyPolicyUrl.
PATCH /v1/appInfos/{app_info_id} — primaryCategory relationship (e.g. {"type":"appCategories","id":"LIFESTYLE"}).
PATCH /v1/appStoreVersions/{version_id} — copyright (<year> <company name>).
Upload screenshots:
asc screenshots upload --version-localization "{version_loc_id}" \
--path "{screenshots_dir}" --device-type "IPHONE_65"
The product/subscription must exist in App Store Connect with the EXACT id from `site-config.ts > payment.productIds.apple`.
asc subscriptions groups localizations create --group {sub_group_id} --locale en-US --name "<group display name>"
asc subscriptions localizations create --subscription-id {subscription_id} --app {asc_app_id} \
--locale en-US --name "<plan name>" --description "<one-line benefit>"
asc subscriptions pricing equalize --subscription-id {subscription_id} \
--base-price "<price>" --base-territory "<ISO3>" --confirm
node mobile-app/scripts/asc-api.mjs GET "/v1/territories?limit=200"
asc subscriptions review screenshots create --subscription-id {subscription_id} \
--file "{screenshots_dir}/<paywall>.png"
Target state: subscription shows READY_TO_SUBMIT. If a screenshot asset gets stuck in processing, delete it and re-upload.
```bash
# content rights (no third-party content is the common case)
asc apps update --id {asc_app_id} --content-rights DOES_NOT_USE_THIRD_PARTY_CONTENT
age rating: fetch, then PATCH honest answers (everything NONE/false except what the app has)
node mobile-app/scripts/asc-api.mjs GET "/v1/appInfos/{app_info_id}/ageRatingDeclaration"
node mobile-app/scripts/asc-api.mjs PATCH /v1/ageRatingDeclarations/ /tmp/age-req.json
- **Export compliance**: add `"ITSAppUsesNonExemptEncryption": false` to `infoPlist` in `mobile-app/app.config.ts` (standard HTTPS-only apps) so every build auto-answers it.
- **App Privacy questionnaire is WEB-ONLY** — the API cannot complete it. Give the user the direct link `https://appstoreconnect.apple.com/apps/{asc_app_id}/distribution/privacy` plus the exact declarations to click, derived from what the app actually collects (this boilerplate baseline: email + user content, linked to identity, no tracking). This is the one manual gate before submission.
</phase>
<phase n="F" title="Review details, validate, submit">
```bash
# review details — the demo account is built into the boilerplate auth
node mobile-app/scripts/asc-api.mjs POST /v1/appStoreReviewDetails /tmp/review-req.json
review-req.json: contact name/email/phone from the user, demoAccountName: "appstoretest@email.com", demoAccountPassword: "123456", demoAccountRequired: true, and notes explaining the OTP flow: "Sign in with the email OTP flow: enter appstoretest@email.com, the one-time code is fixed to 123456 for this account." Describe the core flow in 2-3 sentences.
asc validate --app {asc_app_id} --version "<version>" --platform IOS --output json
Fix every severity: error (the phases above cover all standard ones). When clean AND the user confirms App Privacy is published:
asc review submit --app {asc_app_id} --version "<version>" --dry-run --output table
asc review submit --app {asc_app_id} --version "<version>" --confirm
Report: version, build number, what was submitted, expected review timeline, and how to monitor (asc status --app {asc_app_id}).
<failure_modes>
asc validate error: missing screenshots → wrong display type or wrong dimensions. App Store "iPhone 6.5 Display" = IPHONE_65; screenshots must be exactly 1242x2688.
- Screenshots show a fake
/store route, marketing card, or invented overlay copy → discard the set. Capture the real app screens only, with demo data if needed.
pricing availability edit: app availability not found → availability was never initialized; use POST /v2/appAvailabilities (v2!) with all territories, then retry.
- Subscription review screenshot stuck in AWAITING_UPLOAD/processing → delete the asset and re-upload the PNG.
- Paywall screenshot shows "Continue (Dev)" →
EXPO_PUBLIC_ALLOW_DEV_PAYMENT_BYPASS was true during capture; set it false, relaunch, recapture.
- Maestro types stray characters into inputs → erase and retype (
eraseText: 50 then inputText), keep one input per flow step.
- Wrong app loads in simulator → another Metro on port 8081; kill it or pin ports (see
docs/troubleshooting.md).
- Review rejection: terms not visible → subscription apps must link Terms of Use in the description or EULA field; Phase C appends it.
- Submission blocked on App Privacy → web-only questionnaire not published yet; it must show "published" before submit.
</failure_modes>
<success_metrics>
- All screenshots are 1242x2688 raw captures of the real app UI, demo data only, uploaded to the right localization +
IPHONE_65 display type.
- Metadata, category, copyright, age rating, content rights all set via API (idempotent — re-running is safe).
- Subscription (if any)
READY_TO_SUBMIT with localized name, price, availability, and review screenshot.
asc validate returns zero errors.
- Review submitted only after explicit user confirmation, with the demo account in review notes.
</success_metrics>