Release & Store Review
App Store Connect, Play Console, review rejections, phased release, forced update, policy
- 01
Fastlane says the upload succeeded — what still has to happen on each store before anyone can download that build?
EasyUpload 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 … - 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?
EasyBoth 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 { … - 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?
EasyOnly 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") … - 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?
MediumMost 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 … - 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?
MediumFive 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; } … - 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?
MediumApple'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, … - 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?
MediumNeither 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. … - 08
Every TestFlight build shows "Missing Compliance" and Play has greyed out the production release button — what are the two consoles waiting for?
MediumNeither 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"> … - 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?
MediumNeither 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
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?
MediumOnly 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
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?
MediumThe 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
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?
MediumA 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
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?
MediumPlay 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
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?
HardExpedited 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
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?
HardRemoval, 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
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?
HardA 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 …