Submitting to the App Store

What an iOS release really needs: App Store Connect, TestFlight, privacy declarations, and the rules that cause rejections.

Syntaxeas submit --platform ios --profile production --latest

An iOS binary reaches users through App Store Connect. You create an app record there whose bundle identifier matches ios.bundleIdentifier exactly, then upload builds against it.

Uploading

eas submit --platform ios --profile production --latest uploads the most recent finished build. The submit profile in eas.json needs ascAppId, and authenticating with an App Store Connect API key (ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId) is steadier than an Apple ID password, especially in CI. After upload, Apple processes the build for roughly ten to fifteen minutes before it appears in TestFlight.

TestFlight

Internal testers on your team get the build as soon as processing finishes. External testers require a short Apple review of the build first. Nothing goes to the public store until you submit it for review in App Store Connect and release it.

What actually catches people

A permission prompt with no purpose string in infoPlist. A missing PrivacyInfo.xcprivacy declaration for a required-reason API, generated from ios.privacyManifests. Privacy answers in the listing that do not match what the app collects. No demo account for a login-only app. An app that creates accounts but offers no way to delete one. None are code bugs, and all are rejections.

Example

Example · tsx
// app.config.ts - the iOS fields App Store Connect inspects
import type { ConfigContext, ExpoConfig } from "expo/config";

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: "Trailhead",
  slug: "trailhead",
  version: "1.2.0", // the number shown on the store listing
  ios: {
    // Must match the App Store Connect record exactly; it cannot be changed later.
    bundleIdentifier: "com.example.trailhead",
    supportsTablet: true,
    // Purpose strings. A prompt without one is a rejection, not a warning.
    infoPlist: {
      NSCameraUsageDescription:
        "Trailhead uses the camera so you can add a photo to a trail report.",
      NSPhotoLibraryUsageDescription:
        "Trailhead attaches photos you choose to a trail report.",
    },
    // Written into PrivacyInfo.xcprivacy at build time. CA92.1 is the reason
    // code for reading UserDefaults written by this app only.
    privacyManifests: {
      NSPrivacyAccessedAPITypes: [
        {
          NSPrivacyAccessedAPIType: "NSPrivacyAccessedAPICategoryUserDefaults",
          NSPrivacyAccessedAPITypeReasons: ["CA92.1"],
        },
      ],
    },
  },
});

When to use it

  • Get a build in front of internal testers through TestFlight before anyone outside the team sees it.
  • Fix a rejection for a missing camera purpose string and resubmit the same day.
  • Fill in the privacy questionnaire so it matches what the app actually sends.
  • Automate uploads from CI using an App Store Connect API key instead of a password.

More examples

The iOS submit profile

ascAppId is the numeric id in the App Store Connect URL for your app, not the bundle identifier. API key authentication is what makes submission work unattended, so set this up before you build a pipeline rather than after. The .p8 file is gitignored and uploaded to EAS, or supplied by the CI environment.

Example · json
{
  "submit": {
    "production": {
      "ios": {
        "ascAppId": "6471234567",
        "appleTeamId": "ABCDE12345",
        "ascApiKeyPath": "./credentials/AuthKey_XXXXXXXXXX.p8",
        "ascApiKeyId": "XXXXXXXXXX",
        "ascApiKeyIssuerId": "69a6de7f-0000-0000-0000-000000000000"
      }
    }
  }
}

An in-app account deletion path

On screen: a plain button that opens a native confirmation sheet, with the destructive option in red on iOS, and a spinner while the request runs. Reviewers look for this in the account screen of any app with sign-up. Sending the user to a web page to delete their account is not accepted.

Example · tsx
// DeleteAccountButton.tsx - Apple requires this in any app that creates accounts
import { useState } from "react";
import { ActivityIndicator, Alert, Button, View } from "react-native";

type DeleteAccountButtonProps = { onDeleted: () => void };

export default function DeleteAccountButton({ onDeleted }: DeleteAccountButtonProps) {
  const [busy, setBusy] = useState(false);

  function confirm() {
    Alert.alert("Delete account", "This removes your trips and cannot be undone.", [
      { text: "Cancel", style: "cancel" },
      { text: "Delete", style: "destructive", onPress: run },
    ]);
  }

  async function run() {
    setBusy(true);
    try {
      await fetch("https://api.example.com/me", { method: "DELETE" });
      onDeleted();
    } finally {
      setBusy(false);
    }
  }

  return (
    <View>
      {busy ? <ActivityIndicator /> : <Button title="Delete account" onPress={confirm} />}
    </View>
  );
}

Build, upload, then promote

Submission puts the build in TestFlight. Releasing to the public store is still a human action in App Store Connect: you pick the build, answer the export-compliance question, and submit for review. Keep --latest for day-to-day TestFlight drops and --id when you need to reship an older build.

Example · bash
# Build and hand the binary straight to App Store Connect
eas build --platform ios --profile production --auto-submit

# Or upload a build that already finished
eas submit --platform ios --profile production --latest

# Or a specific one, by the id from eas build:list
eas submit --platform ios --id 4f1a2b3c-0000-0000-0000-000000000000

Discussion

  • Be the first to comment on this lesson.