● CLI & Deployment
apple-release
Pre-flight checklist for Apple App Store submissions of the Cattle Logic mobile app. Use BEFORE running `eas submit -p ios` or uploading a build to App Store Connect. Catches every Apple rejection class this app has historically hit (2.3.10 screenshots, 3.1.1 external-payment disclosure, 5.1.1(ii) purpose strings, 5.1.1(v) account deletion, 5.1.2 data-use declarations). Verifies `mobile/app.json`, `mobile/eas.json`, version bumps, in-app disclosure modals, and the account-deletion path before you ship.
/apple-release
Use this skill BEFORE every iOS App Store submission. It is a pre-flight checklist — work through each step in order. Several steps run grep/file checks the agent can verify automatically; others require user judgement on App Store Connect state.
Project: Cattle Logic (com.cattlemanagement.mobile, ASC app id 6762011755)
EAS project: dfc63cd4-060b-4e87-aa04-c6d33b8e3d35 owned by ricbenmore
Apple Team: 248U39D5G7 · Apple ID: cattlelogic.app@gmail.com
How this skill works
Walk every step. After each one, mark [OK] / [FIX NEEDED] and surface remaining work to the user. Do not skip steps — Apple's rejection patterns are non-obvious; the entire point of this skill is to catch them all in one pass.
If you find a regression, fix it, bump the iOS buildNumber, and re-run this skill from Step 1. Build numbers must monotonically increase.
When a step says "GREP", run the literal command in Bash. When a step says "ASK USER", surface the question with the exact wording shown.
Step 1 — Version + Build Numbers
Why: Apple rejects duplicate CFBundleVersion. The build number must be strictly greater than every previous submission to App Store Connect (including ones that were rejected or removed).
GREP:
cat mobile/app.json | python3 -c "import json,sys; d=json.load(sys.stdin); print('iOS version=%s buildNumber=%s' % (d['expo']['version'], d['expo']['ios']['buildNumber'])); print('Android versionCode=%s' % d['expo']['android']['versionCode']); print('runtimeVersion=%s' % d['expo'].get('runtimeVersion'))"
cat mobile/package.json | python3 -c "import json,sys; print('package.json version=%s' % json.load(sys.stdin)['version'])"
CHECK:
expo.ios.buildNumber> every previously-submitted build (check ASC: https://appstoreconnect.apple.com/apps/6762011755/distribution/ios)expo.android.versionCode> every previously-submitted versionCodeexpo.versionmatchesmobile/package.jsonversionruntimeVersionpolicy: bump it ONLY when this build adds/removes/changes native modules, permissions, or Info.plist keys. JS-only changes leave it pinned (so previous installs can OTA-update).
ASK USER if you aren't sure of the last submitted build number: "What was the last build number on App Store Connect for app id 6762011755? I need to confirm expo.ios.buildNumber exceeds it."
Step 2 — Privacy Purpose Strings (Guideline 5.1.1(ii))
Why: Apple rejected an earlier build for vague camera/photo-library purpose strings. Every iOS permission must explain WHAT the app does + a CONCRETE EXAMPLE.
Apple's failure mode: "App needs camera access" or "Cattle Logic uses the camera" — both are too vague. Apple's pass mode: "Cattle Logic uses the camera so you can document cattle in your operation — for example, photographing arrival receipts..."
GREP:
python3 -c "
import json
d = json.load(open('mobile/app.json'))
plist = d['expo']['ios'].get('infoPlist', {})
required = [
'NSCameraUsageDescription',
'NSMicrophoneUsageDescription',
'NSPhotoLibraryUsageDescription',
'NSPhotoLibraryAddUsageDescription',
]
for k in required:
v = plist.get(k, '')
ok = 'for example' in v.lower() and len(v) > 80
print(('OK ' if ok else 'FAIL '), k, '->', v[:80] + ('...' if len(v) > 80 else ''))
"
CHECK every required key exists AND the value contains the literal phrase "for example" AND is at least 80 characters. If expo.ios.infoPlist block doesn't exist at all, you must add it explicitly — relying on plugin-injected defaults from expo-camera / expo-image-picker is fragile.
CONDITIONAL (only if these features are actually used — grep mobile/src/ first):
NSLocationWhenInUseUsageDescription— required if any screen callsexpo-locationNSContactsUsageDescription— required if any screen reads contactsNSCalendarsUsageDescription— required if any screen reads calendarNSBluetoothAlwaysUsageDescription— required if BLE is used
DO NOT include NSFaceIDUsageDescription — this app does not use Face ID. Apple rejects builds with unused permission strings as misleading. Same rule applies to any permission key whose feature isn't actually wired.
If a required string is missing or vague, ASK USER for the WHAT + EXAMPLE wording. Default template:
"Cattle Logic uses the [resource] so you can [user task] — for example, [concrete cattle-feedlot example]."
Step 3 — Account Deletion (Guideline 5.1.1(v))
Why: Apple rejected v1.0.0 for missing in-app account deletion. Apps that allow account creation MUST allow account deletion (not deactivation).
GREP:
grep -n "Delete Account\|deleteAccount" mobile/app/\(settings\)/profile.js | head -10
grep -n "delete_account" backend/accounts/views.py | head -5
grep -n "delete-account" backend/subscriptions/middleware.py | head -3
CHECK:
- The mobile delete button is unconditionally visible on the profile screen (not gated behind subscription state, owner role, or any other conditional that could hide it during Apple review).
- The button opens a confirmation modal with password entry (defense against fat-finger deletes).
- The backend
delete_accountendpoint is inSubscriptionMiddleware.EXACT_EXEMPT_PATHSso it works even when the subscription is suspended/cancelled. - The endpoint cancels active Stripe subscriptions + detaches payment methods BEFORE anonymizing PII (prevents orphaned charges). See
backend/accounts/views.py:_cancel_active_subscriptions_for_user. - Password is cleared from React state in
finallyblock (defense-in-depth — no plaintext lingers in memory after the request). - Rate limit:
DeleteAccountThrottleat 3/hour blocks brute-force.
ASK USER (manual verification — Apple wants a video):
"Have you recorded a screen recording on a physical iOS device showing: (1) signing in with the demo account, (2) navigating to Profile → Delete Account, (3) the full deletion flow from initiation to confirmation? Apple requires this in the App Review Information Notes field."
Step 4 — External Payment Disclosure (Guideline 3.1.1)
Why: Apple rejected v1.8.0 for opening the external billing portal without the StoreKit External Purchase Link Entitlement disclosure modal. Without the modal, Apple treats the link as "accessing paid content outside the app without IAP" — automatic 3.1.1 rejection.
GREP:
grep -rn "openBrowserAsync\|Linking.openURL" mobile/src/ mobile/app/ | grep -iE "billing|account|portal|stripe|subscription" | head -20
grep -rn "APPLE_EXTERNAL_LINK_DISCLOSURE\|responsible for managing" mobile/ | head -10
CHECK every external link to a billing/subscription/payment URL:
- Wrapped in
Alert.alertshowing the Apple-mandated disclosure copy (verbatim wording — do not paraphrase):"You're about to visit cattlelogicapp.com. Cattle Logic LLC is responsible for managing all transactions made on its platform. Apple is not responsible for the privacy or security of transactions made with this developer."
- Two buttons: "Stay in App" (cancel style) and "Continue".
- Opens via
expo-web-browser(WebBrowser.openBrowserAsync) — this is SFSafariViewController on iOS. Never an in-app<WebView>.
GREP for forbidden CTAs (Apple flags in-app purchase wording even with disclosure):
grep -rn -iE "subscribe now|buy now|upgrade now|purchase|in-app purchase" mobile/app/ mobile/src/components/ | grep -v ".test.js" | head -10
None of these strings should appear in user-visible UI. Only "Manage on the website" / "Manage subscription" wording is allowed (those are management, not purchase, CTAs).
ASK USER (manual — Apple Developer step):
"Has the StoreKit External Purchase Link Entitlement been requested + approved at https://developer.apple.com/contact/request/storekit-external-purchase-link/ for app id 6762011755? Submission is allowed only after Apple grants the entitlement (separate from app review)."
Step 5 — Data Use & Sharing Declarations (Guideline 5.1.2)
Why: This project has historically been rejected for misaligned App Privacy declarations. App Store Connect's "App Privacy" section must declare every data type the app actually collects — Apple cross-checks against the binary.
GREP for actual data collection:
# Identify PII and telemetry the app sends
grep -rn -iE "email|phone_number|first_name|last_name|firstName|lastName" mobile/src/services/ | grep -iE "post|put|patch|register|signup|profile|auth" | head -20
# Push tokens
grep -rn "device_token\|fcm_token\|expo-notifications" mobile/src/services/ | head -10
# Photos / videos uploaded
grep -rn "ImagePicker\|FormData.*photo\|FormData.*video\|cattle_media\|file_url" mobile/src/services/ | head -10
# Sentry — telemetry collection
grep -n "Sentry.init" mobile/app/_layout.js | head -3
# Privacy policy URL source of truth
grep -n "PRIVACY\|privacy" mobile/src/constants/legal.js
DECLARED-VS-ACTUAL CHECKLIST. Confirm each row in App Store Connect → App Privacy:
| Data type | Collected? | Linked to user? | Used for tracking? | App Privacy declaration required |
|---|---|---|---|---|
| YES (signup, login) | YES | NO | Identifiers → Email | |
| Name | YES (profile) | YES | NO | Contact Info → Name |
| Phone | YES (profile, optional) | YES | NO | Contact Info → Phone |
| Photos / Videos | YES (cattle media uploads) | YES | NO | User Content → Photos or Videos |
| Push token | YES (FCM device token) | YES | NO | Identifiers → Device ID |
| Crash data | YES (Sentry) | NO (anonymized) | NO | Diagnostics → Crash Data |
| Performance data | YES (Sentry traces) | NO | NO | Diagnostics → Performance Data |
ASK USER (manual verification):
"Visit https://appstoreconnect.apple.com/apps/6762011755/distribution/privacy and confirm every row in the table above is declared with the listed linkage and tracking flags. Apple cross-checks this against the binary — under-declaration is the most common 5.1.2 rejection cause."
PRIVACY POLICY URL CHECK:
grep -n "PRIVACY\|TERMS" mobile/src/constants/legal.js
Confirm the Iubenda-hosted Privacy Policy URL set in App Store Connect → App Information → "Privacy Policy URL" matches LEGAL_URLS.PRIVACY from this file.
FUTURE WATCH ITEM: If you ever add react-native-tracking-transparency or any AppTrackingTransparency call, you must add NSUserTrackingUsageDescription to mobile/app.json infoPlist. The app currently does not use ATT — leave the key out.
Step 6 — Screenshots (Guideline 2.3.10)
Why: Apple rejected v1.0.0 because screenshots showed the macOS menu bar — captured on macOS instead of in the iOS Simulator. Apple requires screenshots that look like the actual iOS device the user will run on.
CAPTURE METHOD:
# Boot the desired simulator first via Xcode → Window → Devices and Simulators
xcrun simctl io booted screenshot ~/Desktop/cattle-logic-iphone17promax.png
REQUIRED DEVICE SIZES (current App Store Connect specs):
- iPhone 6.7" (1290 × 2796) — iPhone 15 Pro Max / 16 Pro Max / 17 Pro Max
- iPhone 6.5" (1284 × 2778) — iPhone 14 Plus
- iPad 13" (2064 × 2752) — iPad Pro 13"
- iPad 12.9" (2048 × 2732) — iPad Pro 12.9"
CONVENTIONS:
- Status bar should show "9:41" (Apple's iconic time). In the simulator:
xcrun simctl status_bar booted override --time 9:41 --batteryState charged --batteryLevel 100 --cellularBars 4 --wifiBars 3 - No macOS chrome (menu bar, dock, window borders). The
xcrun simctl io booted screenshotcommand captures only the simulator content — do NOT use macOS Cmd+Shift+4 or system screenshot shortcuts. - Screenshots should reflect the app in use — show real cattle data, not empty states (use a seeded demo operation).
ASK USER:
"Have all device-size screenshots been re-captured via
xcrun simctl io booted screenshotsince the previous rejection? Confirm none show macOS menu bar or chrome."
Step 7 — Required Capabilities + Entitlements
Why: Mismatches between app.json and the App Store Connect listing cause "Asset validation failed" errors at submission time.
GREP:
python3 -c "
import json
d = json.load(open('mobile/app.json'))
ios = d['expo']['ios']
plist = ios.get('infoPlist', {})
print('bundleIdentifier =', ios.get('bundleIdentifier'))
print('appleTeamId =', ios.get('appleTeamId'))
print('googleServicesFile =', ios.get('googleServicesFile'))
print('ITSAppUsesNonExemptEncryption =', plist.get('ITSAppUsesNonExemptEncryption'))
"
CHECK:
bundleIdentifieriscom.cattlemanagement.mobile(matches ASC app id 6762011755)appleTeamIdis248U39D5G7ITSAppUsesNonExemptEncryptionisfalse— saves the App Store Connect encryption questionnaire on every upload. ⚠ Legal review: this app usesexpo-cryptoandexpo-secure-store. Both rely on Apple-provided crypto APIs which are exempt under U.S. EAR ECCN 5D992 — thefalsedeclaration is correct as long as no custom non-standard cryptographic implementations are added. Re-confirm with legal counsel before each major release.googleServicesFilepoints to./GoogleService-Info.plistand that file actually exists inmobile/.
Step 8 — EAS Build + Submit Commands
Why: Wrong profile = wrong API URL or wrong distribution channel. Production must use the production profile in mobile/eas.json.
PRODUCTION BUILD + AUTO-SUBMIT (one shot):
cd mobile
eas build --platform ios --profile production --auto-submit
OR split (preferred — gives you a chance to inspect the build before submitting):
cd mobile
eas build --platform ios --profile production
# wait for build to finish (~25-40 min)
eas submit --platform ios --latest
WATCH BUILD STATUS:
- https://expo.dev/accounts/ricbenmore/projects/cattle-management/builds
- Or CLI:
eas build:list --platform ios --limit 5
AFTER SUBMIT:
- ASC processes the upload (~5 min)
- Then in App Store Connect: select the new build for the listing → fill release notes → submit for review
- Watch status: https://appstoreconnect.apple.com/apps/6762011755/distribution/ios
NOTE: The production profile has autoIncrement: true — EAS will bump buildNumber and versionCode automatically on every build. Manual bumps in app.json are still recommended (single source of truth) but EAS will not let you submit a duplicate build number.
Step 9 — App Store Connect Metadata Sanity
Why: Apple rejects builds where the metadata in App Store Connect doesn't match the actual app behavior.
ASK USER:
- Are demo account credentials in App Review Information still valid? Apple uses these to log in. Test them yourself before submitting.
- Is the screen recording for Guideline 5.1.1(v) (Account Deletion) uploaded in the App Review Information → Notes field? See Step 3.
- Does the app description in ASC mention any features that are not in this build (e.g., a feature you removed)? Apple flags this as 2.3.10 misleading metadata.
- Are the App Privacy declarations (App Store Connect → App Privacy) up to date with Step 5's declared-vs-actual table?
- Is the Privacy Policy URL set to the Iubenda-hosted policy referenced by
mobile/src/constants/legal.js?- Has the StoreKit External Purchase Link Entitlement been approved (Step 4)?
Step 10 — Post-Submit Watch + Rejection Loop
Why: First Apple message typically arrives within 24–48 hours. If rejected, the rejection details show on the submission page — these become input to the NEXT iteration of this skill.
POST-SUBMIT:
- Bookmark the submission page: https://appstoreconnect.apple.com/apps/6762011755/distribution/ios
- The "Submission Status" updates from "Waiting for Review" → "In Review" → "Pending Developer Release" or "Rejected"
IF REJECTED:
- Read the rejection message carefully — Apple cites a specific guideline number (e.g., 5.1.1(ii))
- Map the guideline to the relevant step in this skill:
Guideline Skill Step 2.3.10 (Accurate Metadata) Step 6 (Screenshots) or Step 9 (Description) 3.1.1 (Payments — IAP) Step 4 (Disclosure modal + entitlement) 5.1.1(ii) (Privacy purpose strings) Step 2 5.1.1(v) (Account Deletion) Step 3 5.1.2 (Data Use & Sharing) Step 5 - Apply the fix
- BUMP the iOS
buildNumber(+1) so the new submission isn't a duplicate - Re-run this skill from Step 1
- Add the rejection to
CHANGELOG.mdso the institutional memory persists
Reference: Rejection History for This App
| Submission | Build | Apple Guideline | Root cause | Fix location |
|---|---|---|---|---|
| 31295540 (removed) | 1.0.0 (17) | 5.1.1(ii) Purpose strings | Vague camera/photo wording | mobile/app.json expo.ios.infoPlist (Step 2) |
| 02b8452f (rejected) | 1.0.0 (17) | 2.3.10 Screenshots | macOS menu bar in screenshots | Re-capture via xcrun simctl (Step 6) |
| 02b8452f (rejected) | 1.0.0 (17) | 3.1.1 Payments | Web-portal link without disclosure | Alert.alert in mobile/src/components/common/GraceBanner.js + mobile/app/(settings)/profile.js (Step 4) |
| 02b8452f (rejected) | 1.0.0 (17) | 5.1.1(v) Account Deletion | Missing UI flow | mobile/app/(settings)/profile.js Delete Account button + backend/accounts/views.py:delete_account (Step 3) |
| (historical) | older | 5.1.2 Data Use | App Privacy declarations under-declared | App Store Connect → App Privacy (Step 5) |
Reference: Files this skill reads
mobile/app.json— versions, infoPlist, pluginsmobile/eas.json— build profiles, submit configmobile/package.json— version syncmobile/src/constants/legal.js— Privacy Policy URL source of truthmobile/src/components/common/GraceBanner.js— disclosure modal locationmobile/app/(settings)/profile.js— delete account UI + manage-subscription disclosurebackend/accounts/views.py—delete_accountendpointbackend/subscriptions/middleware.py— middleware exemption list.github/workflows/— confirms no CI auto-build (manual EAS submit required)
Reference: Companion docs
mobile/EAS_BUILD.md— full EAS build/submit/OTA referenceCHANGELOG.md— rejection history per releasedocs/stripe-prod-secrets.md(gitignored) — webhook + key references for the prod backend