fix(mobile): fail fast when iOS App Store train is closed (#6633)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-29 14:08:23 -07:00 committed by GitHub
parent 3f39d7548b
commit 584cec78c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 53 additions and 1 deletions

View File

@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release'
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
required: false
default: false
type: boolean

View File

@ -30,6 +30,20 @@ BUNDLE_ID = "com.stably.orca.mobile"
TESTFLIGHT_GROUPS = ["peeps"].freeze
DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze
# App Store version states in which the version "train" is terminally closed to
# new TestFlight build uploads (altool rejects with 90186 "train ... is
# closed"). Only approved/released/removed states qualify: a version that is
# merely IN_REVIEW / WAITING_FOR_REVIEW / PROCESSING_FOR_APP_STORE still accepts
# TestFlight builds, so bumping on those would break normal beta iteration.
CLOSED_APP_STORE_STATES = %w[
READY_FOR_SALE
PENDING_DEVELOPER_RELEASE
PENDING_APPLE_RELEASE
REPLACED_WITH_NEW_VERSION
REMOVED_FROM_SALE
DEVELOPER_REMOVED_FROM_SALE
].freeze
def app_store_connect_api_key_from_env
app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
@ -71,6 +85,24 @@ def resolve_requested_version(options, config)
truthy_option?(options[:bump_patch]) ? bump_patch_version(current_version) : current_version
end
# Returns true when `version`'s App Store train is closed to new build uploads.
# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup
# failed). On a nil app or any API error, degrade to "open": we then proceed as
# before this check existed — the upload either succeeds or fails with the same
# 90186 we have always seen, never worse than today's behavior.
def version_train_closed?(app, version)
return false unless app
app
.get_app_store_versions(filter: { versionString: version })
.any? { |app_store_version| CLOSED_APP_STORE_STATES.include?(app_store_version.app_store_state) }
rescue StandardError => error
# Loud, not silent: a swallowed error here un-fixes the 90186 guard, so the
# degraded run must be visible rather than buried.
UI.error("Could not determine App Store state for #{version} (#{error.message}); assuming open and proceeding.")
false
end
def testflight_changelog
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
@ -83,6 +115,26 @@ platform :ios do
config = load_mobile_app_config
version = resolve_requested_version(options, config)
# Fail fast (seconds) if the resolved version's App Store train is already
# closed: Apple would otherwise reject the upload ~20 min later with 90186.
# Bumping the version is left to the human (the bump_patch input or a
# "Prepare mobile X" commit) so the marketing version stays a deliberate,
# release-notes-bearing decision rather than something CI invents.
app =
begin
Spaceship::ConnectAPI::App.find(BUNDLE_ID)
rescue StandardError => error
UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.")
nil
end
if version_train_closed?(app, version)
UI.user_error!(
"iOS version #{version} is already submitted/released on the App Store and cannot accept " \
"new builds. Re-dispatch with bump_patch_version: true (or a higher release_version), " \
"or land a \"Prepare mobile <version>\" commit.",
)
end
latest_build_number = latest_testflight_build_number(
api_key: api_key,
app_identifier: BUNDLE_ID,