Building and Publishing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every page so far has run Places from a development build: Metro serving a bundle over the network, Fast Refresh on, a debug-signed app on a simulator or a tethered device. Publishing replaces every one of those conveniences with its release-time counterpart — the bundle is compiled into the binary instead of fetched, the app is signed with a key a store trusts rather than a throwaway debug key, and the identifiers that were scaffolding during development become permanent: once com.irurueta.places is live on Google Play and the App Store, it can never be changed for that listing.

This page covers getting a signed artifact built and a listing published, once. Automating that — running the build from CI on every merge, and shipping JavaScript-only changes between store releases — is CI/CD and Over-the-Air Updates's subject, and this page deliberately stops at the point where that one picks up.

The baseline is the same as the rest of the section: React Native 0.87 (Android Gradle Plugin 9, compileSdk 37, Kotlin 2.2, and precompiled iOS binaries — shipped since 0.84 — so a clean iOS build no longer compiles React Native’s own C++ from source), React 19.2, and Expo SDK 57.

App Identity

Four pieces of identity travel with every build, and three of them are effectively permanent.

Bundle Identifier and Application ID

iOS calls it the bundle identifier, Android the application id; both are reverse-DNS strings, and Places uses com.irurueta.places for its production listing on both platforms. Keeping them identical across platforms is a convention rather than a requirement, but it makes deep-link configuration, analytics, and push registration far easier to reason about.

The identifier is the primary key of a store listing. Google Play refuses to publish two apps under one application id and refuses to ever reuse one, even after an app is unpublished; on iOS the bundle identifier is bound to an App ID record in the developer portal and to one App Store Connect app. Picking it carelessly is one of the few genuinely irreversible decisions in a mobile project.

Non-production builds get their own identifier — com.irurueta.places.dev, com.irurueta.places.preview — so that a developer build, a staging build, and the App Store build can all sit on the same phone at once without overwriting each other. On Android this also means three distinct entries in the launcher, which is why the display name gets an environment suffix to match.

Display Name, Version, and Build Number

The display name is what the launcher and the home screen show; it is short, it is not the same as the store listing title, and on Android it is truncated aggressively. Places ships as Places in production and Places (Dev) / Places (Preview) elsewhere.

Two numbers travel with every build and they do different jobs:

  • The version (version in Expo config, CFBundleShortVersionString on iOS, versionName on Android) is the human-facing marketing version — 1.4.0. Users see it; stores display it; it is what a release note refers to.

  • The build number (ios.buildNumber / CFBundleVersion on iOS, android.versionCode on Android) is the machine-facing counter. It must strictly increase with every artifact uploaded to a store, and an upload that reuses a number is rejected outright. Android’s versionCode is an integer; iOS’s buildNumber is a dot-separated string, conventionally just an integer too.

The build number is the single most common cause of a failed upload, and it is exactly the kind of bookkeeping worth handing to a tool: EAS Build’s autoIncrement (below) tracks the last number it used and bumps it, either locally from the project files or — with cli.appVersionSource set to remote — from EAS’s own server-side record, which is what makes the counter correct even when builds are triggered from several machines.

app.json and app.config.ts

An Expo project’s identity lives in one config object. app.json is the static form and is enough for a project whose identity never varies; app.config.ts is the same object produced by a TypeScript function, and is what a project needs the moment any value depends on which environment is being built. The two can coexist — app.config.ts receives the parsed app.json as config and spreads it — and that is the arrangement Places uses.

That config is not merely metadata. With Expo’s Continuous Native Generation workflow, npx expo prebuild generates the android/ and ios/ directories from it, which means the identity, the icons, the splash screen, and every config-plugin-driven native setting are derived from a single checked-in TypeScript file rather than hand-edited in Info.plist and AndroidManifest.xml. A project that keeps android/ and ios/ in version control instead (the bare workflow, discussed in Tooling: Metro and Project Structure) edits those native files directly and uses the Expo config only for what the JavaScript side reads.

Icons and Splash Screens

The app icon is a single square source image — 1024x1024 PNG, no transparency, no rounded corners, since both platforms apply their own mask — from which the build generates every size each platform wants. Android additionally wants an adaptive icon: a foreground layer plus a background colour or image, which the launcher composes and animates into whatever shape the device’s theme uses, so anything important must stay inside the central safe zone rather than running to the edges.

Splash screens are configured through the expo-splash-screen config plugin, which writes the native launch screen on both platforms from one declaration: an image, its width, a resize mode, a background colour, and a separate dark-mode variant. At runtime the same package controls when the splash screen goes away — SplashScreen.preventAutoHideAsync() at module scope holds it up, and SplashScreen.hideAsync() dismisses it once fonts are loaded and the first screen is ready to paint, which is what keeps a user from seeing an empty white frame between the launch screen and the app. Producing and sizing the underlying image assets is Images and Icons's subject.

Places: app.config.ts and Per-Environment Configuration

Places's config file maps an APP_ENV value onto an identity — bundle id, display name, and API base URL — and reads EXPO_PUBLIC_API_URL from the environment so that a build profile (or a CI job, or a developer’s shell) can override the default without editing any code:

// app.config.ts
import type { ConfigContext, ExpoConfig } from 'expo/config';

type Environment = 'development' | 'preview' | 'production';

interface EnvironmentIdentity {
  bundleId: string;
  name: string;
  defaultApiUrl: string;
}

const identities: Record<Environment, EnvironmentIdentity> = {
  development: {
    bundleId: 'com.irurueta.places.dev',
    name: 'Places (Dev)',
    defaultApiUrl: 'http://localhost:8080',
  },
  preview: {
    bundleId: 'com.irurueta.places.preview',
    name: 'Places (Preview)',
    defaultApiUrl: 'https://staging.api.places.example.com',
  },
  production: {
    bundleId: 'com.irurueta.places',
    name: 'Places',
    defaultApiUrl: 'https://api.places.example.com',
  },
};

const environment = (process.env.APP_ENV ?? 'development') as Environment;
const identity = identities[environment];

// EXPO_PUBLIC_API_URL is set per build profile in eas.json; the map above is only the fallback
// for a developer running `npx expo start` with nothing exported.
const apiUrl = process.env.EXPO_PUBLIC_API_URL ?? identity.defaultApiUrl;

export default ({ config }: ConfigContext): ExpoConfig => ({
  ...config,
  name: identity.name,
  slug: 'places',
  version: '1.4.0',
  orientation: 'portrait',
  scheme: 'places',
  userInterfaceStyle: 'automatic',
  icon: './assets/icon.png',
  ios: {
    bundleIdentifier: identity.bundleId,
    buildNumber: '1',
    supportsTablet: true,
  },
  android: {
    package: identity.bundleId,
    versionCode: 1,
    adaptiveIcon: {
      foregroundImage: './assets/adaptive-icon.png',
      backgroundColor: '#ffffff',
    },
  },
  plugins: [
    [
      'expo-splash-screen',
      {
        image: './assets/splash-icon.png',
        imageWidth: 200,
        resizeMode: 'contain',
        backgroundColor: '#ffffff',
        dark: { backgroundColor: '#101418' },
      },
    ],
  ],
  extra: {
    environment,
    apiUrl,
  },
});

Two things are worth being precise about. First, the EXPO_PUBLIC_ prefix is what makes a variable readable from app code as process.env.EXPO_PUBLIC_API_URL — Metro inlines it into the bundle at build time, which is how apiClient.ts picks up the right base URL without any runtime configuration fetch. Second, and for exactly that reason, an EXPO_PUBLIC_* value is public: it ships inside the binary and anyone with the .ipa or .aab can read it back out. An API base URL, a non-sensitive project id, or a feature-flag default belong there; a signing secret or a privileged API key never does, as Security covers at length.

version and the two build numbers are shown inline here for clarity; with autoIncrement enabled on the production profile, EAS overrides buildNumber and versionCode at build time, so the checked-in values are only the starting point.

EAS Build and EAS Submit

Building a release artifact locally means having the full native toolchain installed and correct: Xcode with a matching command-line tools selection and a populated Keychain for iOS, a JDK plus the Android SDK and NDK for Android, and — because App Store submission requires signing on macOS — a Mac for anything iOS at all. EAS Build moves that onto Expo’s hosted macOS and Linux workers: eas build uploads the project, runs the native build there, and returns a downloadable artifact, with the machine configuration, the credentials, and the toolchain versions all described by files in the repository rather than by whatever happens to be installed on one laptop.

eas.json is where that description lives. Each entry under build is a profile — a named set of build options — and profiles can extend one another. Places uses the three conventional ones:

{
  "cli": {
    "version": ">= 16.0.0",
    "appVersionSource": "remote"
  },
  "build": {
    "base": {
      "node": "24.4.0",
      "android": {
        "image": "latest"
      },
      "ios": {
        "image": "latest"
      }
    },
    "development": {
      "extends": "base",
      "developmentClient": true,
      "distribution": "internal",
      "channel": "development",
      "env": {
        "APP_ENV": "development",
        "EXPO_PUBLIC_API_URL": "http://localhost:8080"
      },
      "android": {
        "buildType": "apk"
      },
      "ios": {
        "simulator": true
      }
    },
    "preview": {
      "extends": "base",
      "distribution": "internal",
      "channel": "preview",
      "env": {
        "APP_ENV": "preview",
        "EXPO_PUBLIC_API_URL": "https://staging.api.places.example.com"
      },
      "android": {
        "buildType": "apk"
      }
    },
    "production": {
      "extends": "base",
      "autoIncrement": true,
      "channel": "production",
      "env": {
        "APP_ENV": "production",
        "EXPO_PUBLIC_API_URL": "https://api.places.example.com"
      },
      "android": {
        "buildType": "app-bundle"
      }
    }
  },
  "submit": {
    "production": {
      "android": {
        "serviceAccountKeyPath": "../secrets/PLAY_SERVICE_ACCOUNT_KEY.json",
        "track": "internal"
      },
      "ios": {
        "appleId": "APPLE_ID_EMAIL_PLACEHOLDER",
        "ascAppId": "APP_STORE_CONNECT_APP_ID_PLACEHOLDER",
        "appleTeamId": "APPLE_TEAM_ID_PLACEHOLDER"
      }
    }
  }
}

Every value in the submit block above is a placeholder, and the service-account key it points at is a real credential that must never be committed — it belongs in a secret store that CI injects at build time, which is where CI/CD and Over-the-Air Updates takes it up.

The three profiles differ in ways that matter:

Profile What it produces and who installs it

development

A development build: a debug-signed app containing expo-dev-client, which loads its JavaScript from a Metro server rather than from the binary. It is the day-to-day build for anyone working on native code or on a library Expo Go cannot host. ios.simulator makes the iOS artifact a simulator build, which needs no signing and no registered device.

preview

A release-configuration build with distribution: internal — installable from an EAS link on registered devices and on any Android device, without going near a store. It is what a designer, a tester, or a product owner installs to check a change on a real device. Android ships as an APK here because an .aab cannot be side-loaded.

production

The artifact a store accepts: a signed Android App Bundle and a distribution-signed .ipa, with autoIncrement taking care of versionCode/buildNumber.

eas credentials manages signing material for both platforms. Expo can generate and hold an Android upload keystore and an iOS distribution certificate plus provisioning profile on the project’s behalf, retrieving them for each build; alternatively a project can supply its own and keep them in credentials.json, which is the right choice when the same keys are shared with a Fastlane setup or an existing release process. Either way the keystore file itself never belongs in the repository.

EAS Submit (eas submit --platform android --profile production) uploads a finished artifact to Google Play or App Store Connect using the submit configuration above, so that the build-then-upload pair is two commands rather than a manual trip through two web consoles. It is independent of EAS Build — a locally built artifact can be submitted with --path.

Local builds remain fully supported and are sometimes preferable: eas build --local runs the same recipe on the current machine, and a project in the bare workflow can simply run Gradle and Xcode directly, as the next two sections do. The trade-off is ownership of the toolchain — reproducibility and secret handling become the project’s problem rather than the service’s.

Android Release Builds

This section covers what a React Native release needs from Android’s signing and publishing pipeline; the native Android pages document the same pipeline in full, independent of React Native — see Building, Signing and Preparing a Release and Publishing on Google Play.

The Upload Keystore and Play App Signing

Android requires every APK and App Bundle to be signed, and Google Play splits that into two keys:

  • The upload key is the one the project holds. Its keystore is generated once with keytool, and it is what ./gradlew bundleRelease signs with. It proves to Play that an upload came from the project.

  • The app signing key is the one Play holds under Play App Signing. Play verifies the upload signature, strips it, and re-signs the artifacts it generates for devices with the app signing key — which is the key devices actually validate against, and the key that must remain constant for the lifetime of the app.

The point of the split is recoverability. If the upload keystore is lost or compromised, Google can register a new upload key and the app keeps updating; if a self-managed app signing key were lost, the listing could never be updated again by anyone. Play App Signing is mandatory for new apps, and it is also what makes Play able to generate per-device split APKs from an App Bundle at all, since it has to re-sign them.

The keystore file and its passwords are the project’s most sensitive build-time assets. The keystore never goes into version control, the passwords never go into a file that does, and losing the backup of both is a genuine incident even with Play App Signing in place.

The Gradle Signing Configuration

In a bare project, release signing is declared in android/app/build.gradle and the actual secrets are read from Gradle properties or the environment, never written inline:

// android/app/build.gradle
android {
    signingConfigs {
        release {
            storeFile file(System.getenv('PLACES_UPLOAD_STORE_FILE')
                    ?: project.findProperty('PLACES_UPLOAD_STORE_FILE'))
            storePassword System.getenv('PLACES_UPLOAD_STORE_PASSWORD')
                    ?: project.findProperty('PLACES_UPLOAD_STORE_PASSWORD')
            keyAlias System.getenv('PLACES_UPLOAD_KEY_ALIAS')
                    ?: project.findProperty('PLACES_UPLOAD_KEY_ALIAS')
            keyPassword System.getenv('PLACES_UPLOAD_KEY_PASSWORD')
                    ?: project.findProperty('PLACES_UPLOAD_KEY_PASSWORD')
        }
    }

    buildTypes {
        release {
            signingConfig signingConfigs.release
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

The four properties are resolved from the environment first, so CI can inject them as masked secrets, and fall back to Gradle properties for a local release build. Those properties belong in the user-level ~/.gradle/gradle.properties, outside the repository:

# ~/.gradle/gradle.properties -- NOT the copy inside the project, which is committed.
# Every value below is a placeholder. Replace them with the real ones locally, and
# supply them as masked environment variables in CI.
PLACES_UPLOAD_STORE_FILE=/Users/you/keystores/places-upload.keystore
PLACES_UPLOAD_KEY_ALIAS=places-upload
PLACES_UPLOAD_STORE_PASSWORD=***REPLACE_WITH_YOUR_KEYSTORE_PASSWORD***
PLACES_UPLOAD_KEY_PASSWORD=***REPLACE_WITH_YOUR_KEY_PASSWORD***

Building then comes down to one Gradle task:

cd android

# Android App Bundle for the Play Store -- the artifact Play wants.
./gradlew bundleRelease
# -> android/app/build/outputs/bundle/release/app-release.aab

# Universal APK -- for side-loading, a QA hand-off, or a non-Play distribution channel.
./gradlew assembleRelease
# -> android/app/build/outputs/apk/release/app-release.apk

# Install the release build on a connected device to sanity-check it before uploading.
./gradlew installRelease

Both tasks run Metro’s release bundling as part of the build: the JavaScript is compiled to Hermes bytecode and packaged as an asset inside the artifact, so the release app has no dependency on a running Metro server (JavaScript Runtime and Hermes covers that compilation step).

AAB or APK, R8, and ABI Splits

An Android App Bundle (.aab) is not installable; it is a publishing format. Play uses it to generate and sign the exact split APKs each device needs — the right screen density, the right language resources, and the right native ABI — so a device downloads materially less than a universal APK would make it download. Play requires it for new apps and for updates to existing ones. An APK is still the right artifact anywhere Play is not involved: internal side-loading, a preview build handed to a tester, an alternative store, or a device with no Play Services at all.

minifyEnabled true turns on R8, which has replaced ProGuard as the shrinker and obfuscator while keeping ProGuard’s rule syntax and the proguardFiles name. It removes unreachable Java/Kotlin code, shortens names, and — with shrinkResources true — drops unreferenced resources. The failure mode to know about is reflection: anything looked up by name at runtime, which in a React Native app means native modules discovered through their generated registration code and any library relying on reflective instantiation, needs a -keep rule in proguard-rules.pro or it will be stripped and fail only in the release build. React Native ships consumer rules for its own code, and well-behaved libraries ship theirs, but a release build is never verified until it has actually been run — which is what installRelease above is for.

When APKs must be distributed directly and size matters, ABI splits produce one APK per native architecture instead of one fat APK carrying every .so:

// android/app/build.gradle -- only needed when distributing APKs outside Play.
android {
    splits {
        abi {
            reset()
            enable true
            universalApk false
            include 'armeabi-v7a', 'arm64-v8a', 'x86_64'
        }
    }
}

Each split APK needs its own increasing versionCode, which is the usual reason this is more trouble than it is worth on Play — where the App Bundle does the same job automatically and correctly.

Play Console Tracks

Google Play stages a release through four tracks, and an artifact can be promoted from one to the next without rebuilding:

Track Audience and purpose

Internal testing

Up to 100 named testers, available within minutes of upload because it skips the full review queue. This is where a build goes first, always — it is the cheapest way to confirm that the signed artifact actually starts.

Closed testing

An invite-only tester list or Google Group, reviewed like a real release. Used for a genuine beta with people outside the team.

Open testing

Anyone who opts in from the store listing. Useful for scale testing and for gathering crash data from real device diversity before committing.

Production

The public listing. Supports a staged rollout — release to a percentage of users, watch crash-free rate and vitals, then increase the percentage or halt the rollout if something is wrong.

Play also enforces a pre-launch report on uploaded builds, running the app on a set of real devices in Google’s lab and reporting crashes, accessibility problems, and security warnings before any user sees it.

iOS Release Builds

This section covers what a React Native release needs from Apple’s signing and distribution pipeline. The native Apple pages document the same pipeline in full, independent of React Native — see Membership, Signing, Capabilities and Info.plist and App Store Connect, TestFlight and App Review.

Certificates, Identifiers, and Provisioning Profiles

iOS release builds turn on three records in the Apple Developer portal, and nearly every "code signing error" is two of them disagreeing. The full native version of this picture, with capabilities and devices included, is the signing figure on Membership, Signing, Capabilities and Info.plist:

  • A signing certificate — Apple Development for debug builds, Apple Distribution for anything shipped — whose private key lives in the macOS Keychain (or in EAS’s managed credential store). The certificate identifies who signed the binary.

  • An identifier, or App ID: the record for com.irurueta.places, which also lists the capabilities the app is entitled to use — push notifications, App Groups, Sign in with Apple, associated domains for the universal links Security describes.

  • A provisioning profile, which binds a certificate to an App ID, carries the resulting entitlements, and — for development and ad hoc distribution — lists the UDIDs of the devices allowed to run the build. An App Store distribution profile lists no devices, because the store handles installation. The profile is embedded in the app bundle at build time and it expires, so a profile that worked last year is a routine build failure this year.

Xcode’s Automatically manage signing creates and refreshes these for a team that has portal access, which is the right default for a small project. eas credentials does the same thing from the command line and stores the results server-side so that CI builds do not need a Mac with a populated Keychain; Fastlane’s match solves it a third way, by keeping the certificates and profiles in an encrypted git repository that every machine syncs from.

Figure: How the iOS Signing Pieces Fit Together

Diagram of the iOS signing pieces: an Apple-issued signing certificate whose private key lives in the Keychain

Archiving and Uploading

A local release build is an archive: in Xcode, select Any iOS Device as the destination and run Product > Archive, which builds in the Release configuration, runs Metro’s release bundling, compiles the JavaScript to Hermes bytecode, and produces an .xcarchive in the Organizer. From there, Distribute App signs an .ipa with the distribution certificate and uploads it to App Store Connect. The same thing runs headlessly with xcodebuild archive followed by xcodebuild -exportArchive, which is what Fastlane’s gym wraps; eas build --platform ios --profile production produces the same artifact on a hosted macOS worker.

Since React Native 0.84 the iOS dependency graph ships as precompiled binaries rather than as source, which removes the long C++ compile that used to dominate a clean release build; a release archive on 0.87 is meaningfully faster than it was on 0.76, though it is still the slowest step in the pipeline.

Uploads land in App Store Connect, which runs automated checks — signature validity, bundle id match, entitlements consistent with the profile, required icon sizes present, privacy manifest well-formed — and rejects the build before a human ever sees it if any of those fail.

TestFlight

Once a build finishes processing, TestFlight distributes it to testers:

  • Internal testers are members of the App Store Connect team, up to 100 of them, and they get the build as soon as processing finishes, with no review.

  • External testers — up to 10,000, invited by email or by a public link — require a beta app review first. That review is lighter than a full App Store review, but it is a real queue with a real turnaround.

Builds expire after 90 days, and each one carries release notes that testers see in the TestFlight app. It is the direct counterpart to Play’s internal and closed tracks, and the same discipline applies: no build goes to review that has not first been installed and launched from TestFlight.

Review Guidelines and Privacy Manifests

App Store review is a human process against the published App Review Guidelines, and the rejections that recur in React Native projects are rarely technical:

  • Minimum functionality — an app that is a thin wrapper around a website, or that duplicates what a mobile web page already does, is rejected under guideline 4.2.

  • Login services — an app that uses a third-party or social login service for the user’s primary account must also offer an equivalent privacy-focused login option (guideline 4.8); Sign in with Apple is one such option.

  • Account deletion must be available in-app for any app that supports account creation.

  • Permission strings — every NSCameraUsageDescription-style key must explain why the app needs that data in terms a user understands; a generic string is a rejection. In an Expo project these are set through the relevant config plugin rather than by editing Info.plist.

  • Demo credentials for anything behind a login must be supplied in the review notes, or the reviewer sees a sign-in wall and stops.

  • Purchases of digital content must go through In-App Purchase.

Separately, Apple requires a privacy manifest: a PrivacyInfo.xcprivacy file declaring the data the app collects and, critically, the required reason APIs it uses — a short list of platform APIs (file timestamps, disk space, user defaults, system boot time) that can be used for fingerprinting and must be justified with an approved reason code. Third-party SDKs on Apple’s list must ship their own manifest and a signature, and the build aggregates all of them into the privacy report submitted with the app. React Native 0.87 and Expo SDK 57 ship manifests covering their own required-reason API usage, so the work left to a project is declaring its own data collection and checking that every third-party SDK it adds carries a manifest too. App Store Connect rejects an upload whose aggregated manifest is missing entries it can detect.

Store Assets and Metadata

The binary is only half of a release. Both stores need a listing, and assembling it is usually the step that delays a first launch:

Asset Google Play App Store

Name

Up to 30 characters

Up to 30 characters

Short description / subtitle

80 characters, shown in search results

30-character subtitle under the name

Full description

4,000 characters

Up to 4,000 characters, plus a separate promotional text field that can be changed without a new build

Icon

512x512 PNG

1024x1024 PNG, no alpha channel

Screenshots

At least 2 per form factor; phone, 7-inch and 10-inch tablet

Required for the largest iPhone and iPad display sizes; smaller sizes are scaled from them

Feature graphic / preview

1024x500 feature graphic

Optional app preview video, up to 30 seconds

Privacy

Data safety form, declared per data type

App privacy "nutrition label", must match the privacy manifest

Other

Content rating questionnaire, target audience, ads declaration, privacy policy URL

Age rating questionnaire, export compliance, support and marketing URLs, review notes and demo account

Both privacy declarations are answered about the whole app including its SDKs, and both stores treat an inaccurate declaration as a policy violation rather than a mistake — an analytics or crash-reporting SDK added casually changes what the app collects, and the listing has to be updated to match.

Localised listings are per-locale copies of the text and screenshots; the store shows the default locale where a translation is missing, so adding a language is an incremental change rather than an all-or-nothing one.

From Commit to Store Listing

flowchart TD Commit["Commit on main
app.config.ts + eas.json describe the release"] Bump["Version and build number
version 1.4.0; autoIncrement bumps versionCode / buildNumber"] Build["Release build
eas build --profile production, or Gradle / Xcode locally"] Commit --> Bump --> Build Build --> AAB["Android: signed .aab
gradlew bundleRelease, upload key"] Build --> IPA["iOS: signed .ipa
Xcode archive or EAS, distribution certificate + profile"] AAB --> PlayUpload["Upload to Play Console
eas submit or manual"] PlayUpload --> PlaySign["Play App Signing re-signs
and generates per-device split APKs"] PlaySign --> Internal["Internal testing"] Internal --> Closed["Closed testing"] Closed --> Open["Open testing"] Open --> PlayProd["Production, staged rollout"] PlayProd --> PlayListing["Google Play listing"] IPA --> ASC["Upload to App Store Connect
eas submit, Xcode Organizer, or Transporter"] ASC --> Checks["Processing and automated checks
signature, entitlements, icons, privacy manifest"] Checks --> TF["TestFlight: internal, then external beta review"] TF --> Review["App Review against the guidelines"] Review --> Release["Release: manual or phased"] Release --> AppStore["App Store listing"]

The two halves are deliberately symmetric: build once per platform, upload, stage through progressively wider audiences, then release. What differs is where the gate sits — Play reviews at the closed-testing stage and lets internal testing through in minutes, while Apple reviews before anything reaches the public listing and again, more lightly, before an external TestFlight group.

Everything downstream of the first box is what automation is for, and CI/CD and Over-the-Air Updates wires exactly this flow into a pipeline — along with the separate question of how to ship a JavaScript-only fix between two store releases.