Release & Store Review
Irbisa · cheatsheetSeptember 13, 2026

Release & Store Review

App Store Connect, Play Console, review rejections, phased release, forced update, policy

Middle Developer16 itemscompressed for a skim
  1. 01

    Fastlane says the upload succeeded — what still has to happen on each store before anyone can download that build?

    Easy

    Upload only hands the bytes to the store; three or four more stages sit between that and a download, and only one of them is the human review everybody talks about.

    #!/usr/bin/env bash
    # Release day is a state machine, not a button. Poll it instead of refreshing a tab.
    set -euo pipefail
    APP_ID=6449012345
    PKG=com.example.app
    …
  2. 02

    You upload a build with a version code the store has already seen — what does each store do, and why is that number burned forever?

    Easy

    Both stores treat the build identifier as a permanent, strictly increasing key, so a repeated or lower number is rejected at upload and stays spent even after you delete the release.

    // app/build.gradle.kts — the number comes from the pipeline, never from a person
    val ciRun = (System.getenv("GITHUB_RUN_NUMBER") ?: "0").toInt()
    val liveCode = (System.getenv("LIVE_VERSION_CODE") ?: "0").toInt()
    
    android {
        defaultConfig {
    …
  3. 03

    QA needs today's build in ten minutes — which Play track and which TestFlight group can do that, and which ones sit in a review queue?

    Easy

    Only Play's internal testing track and a TestFlight internal group hand a build over without a review pass; everything wider than that queues behind a reviewer.

    # fastlane/Fastfile — a lane per distribution speed, because the speeds differ by hours
    
    platform :android do
      # Minutes, no review, up to 100 testers on the list.
      lane :qa do
        gradle(task: "bundleRelease")
    …
  4. 04

    Review rejects your build under a guideline you are certain does not apply — what do you do before you argue, and where does the argument actually happen?

    Medium

    Most rejections you are sure are wrong turn out to be a reviewer who could not reach the feature, so the first move is to reproduce their session rather than to write a rebuttal.

    # fastlane/Fastfile — review notes are code you version, not a box someone retypes
    lane :submit do
      deliver(
        submit_for_review: true,
        automatic_release: false,          # you press the button, not the reviewer's clock
        force: true,                       # no interactive HTML preview in CI
    …
  5. 05

    Your first submission has been rejected three times for three different reasons — which rejections account for most of that, and what catches each before upload?

    Medium

    Five failure modes cover most first-submission rejections, and every one of them is catchable by a script that inspects the archive you are about to upload.

    #!/usr/bin/env bash
    # preflight.sh — runs against the ARCHIVE, not the source tree.
    set -euo pipefail
    APP="build/MyApp.xcarchive/Products/Applications/MyApp.app"
    PLIST="$APP/Info.plist"
    fail() { echo "BLOCKED: $*" >&2; exit 1; }
    …
  6. 06

    A phased release is running on iOS and a 20% staged rollout on Play — what can you still change on each, and what does halting leave users on?

    Medium

    Apple's phased release is a fixed seven-day clock you can only pause or skip, Play's rollout is a dial you can only turn up, and neither one can take a build back off a phone.

    # fastlane/Fastfile — two dials, and what each one refuses to do
    
    platform :ios do
      lane :release do
        deliver(
          submit_for_review: true,
    …
  7. 07

    A build already at 100% on both stores crashes on launch for a third of your users — what are your actual options in the first hour?

    Medium

    Neither store can uninstall or downgrade what is already on a phone, so the first hour is about stopping new victims and starting the forward fix at the same time — there is no rollback to reach for.

    #!/usr/bin/env bash
    # incident.sh — crashing build at 100%. Order matters; run top to bottom.
    set -euo pipefail
    BAD_CODE=14213; PKG=com.example.app; PLAY=https://androidpublisher.googleapis.com/androidpublisher/v3
    
    # T+0  The only lever that reaches phones already running the bad build.
    …
  8. 08

    Every TestFlight build shows "Missing Compliance" and Play has greyed out the production release button — what are the two consoles waiting for?

    Medium

    Neither one is waiting on your binary: Apple wants the export-compliance answer that has to travel with every upload, and Play holds the release button until every applicable App content declaration is finished.

    <?xml version="1.0" encoding="UTF-8"?>
    <!-- MyApp/Info.plist — answer export compliance in the build, once.
         Without this key every upload lands in App Store Connect as
         "Missing Compliance": testers cannot install it and you cannot submit it. -->
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    …
  9. 09

    Your archive is 180 MB, the store says the download is 62 MB, and the AAB you uploaded never lands on a phone — what happens to a binary after upload?

    Medium

    Neither store ships the file you uploaded: both re-sign it and both cut it down to the exact device asking for it.

    # ---------- iOS ----------
    # The archive is the input. Apple re-signs and slices it, so produce the thinned
    # variants locally to see what a device will actually download.
    xcodebuild -exportArchive \
      -archivePath build/MyApp.xcarchive \
      -exportOptionsPlist ExportOptions.plist \
    …
  10. 10

    Security wants every user off builds older than 4.2.0 by Friday — what can you actually force on Android and on iOS, and where does the version floor live?

    Medium

    Only Android can install an update for you; on iOS a forced update is a blocking screen and a store link, so on both platforms the floor itself has to come from your server.

    // Android: Play can install it for you — but only if this user can see the release.
    private val manager = AppUpdateManagerFactory.create(context)
    
    fun enforce(policy: VersionPolicy) {
        manager.appUpdateInfo.addOnSuccessListener { info ->
            val mustBlock = BuildConfig.VERSION_CODE < policy.minSupportedCode
    …
  11. 11

    Product wants to drop Android 8 and iOS 15, and Play is nagging about the target API level — what actually forces your hand, and what happens to users below the new floor?

    Medium

    The target API level is a store deadline you cannot miss; the minimum version is your own choice, and the two do completely different things to the users underneath them.

    // app/build.gradle.kts
    android {
        namespace = "com.example.app"
        compileSdk = 36              // compile against the newest SDK: this part is free
    
        defaultConfig {
    …
  12. 12

    Your paywall shows the reviewer an empty product list, and offers a free trial to someone who already used one — what did that release miss?

    Medium

    A subscription is a store-side product with its own review, its own state machine and its own per-user eligibility, and the app only renders what the store returns for that account.

    import StoreKit
    
    // The store decides eligibility. Your paywall copy does not.
    func offerLine(for product: Product) async -> String {
        guard let sub = product.subscription else { return product.displayPrice }
    …
  13. 13

    Marketing wants a new description and fresh screenshots live today and there is no build ready — what can you actually ship on each store without a binary?

    Medium

    Play treats the store listing as an object you edit and publish independently of any release; Apple ties almost all of it to an app version, so on iOS most of that request still needs a submission.

    # fastlane/Fastfile — the listing is content under review, so treat it like code
    #
    #   fastlane/metadata/en-US/{description,keywords,release_notes}.txt
    #   fastlane/metadata/ru/{description,keywords,release_notes}.txt
    #   fastlane/screenshots/en-US/*.png
    #   fastlane/metadata/android/ru-RU/full_description.txt
    …
  14. 14

    You asked Apple for an expedited review and heard nothing back — what were you actually asking for, and what does the plan look like if the answer is no?

    Hard

    Expedited review is a discretionary favour with a budget attached, so it belongs in a plan as the optimistic branch and never as the mechanism.

    // The lever you own: a gate whose value comes from the server, read per call,
    // with a default chosen for the build that cannot reach config at all.
    class ReleaseControls(private val config: RemoteConfig) {
    
        // WRONG: read once into a val at startup. The flip never reaches a session that
        // is already open — which, during an incident, is most of them.
    …
  15. 15

    An email says your app was removed for a policy violation and installs have flatlined — what is the recovery path, and what should have existed before that email?

    Hard

    Removal, suspension and account termination are three different states with three different exits, and since the only lever you have is an appeal, nearly all of the useful work happened before the email arrived.

    #!/usr/bin/env bash
    # Break-glass check. Run it monthly, not on the morning you need it.
    set -euo pipefail
    PKG=com.example.app
    
    # 1. Is the app still live, and on which track? Play answers faster than the email.
    …
  16. 16

    A store policy deadline lands in four months and nobody on the team has heard of it — how does that reach your roadmap early enough to be boring?

    Hard

    A store policy is a dependency with a fixed date, no negotiation and no slip, so the only way it stays boring is to turn every announcement into a roadmap item with an owner on the day it is published.

    # compliance/register.yml — a policy deadline has the shape of a dependency,
    # so store it like one.
    #   kind: submission -> once the date passes you cannot upload
    #   kind: existing   -> once the date passes the live app is removed
    requirements:
      - id: play-target-api-36
    …