fix(mobile): auto-bump iOS version when App Store train is closed

The iOS release lane auto-increments the TestFlight build number but never
the marketing version. Once a version is submitted/approved/released its
train closes, so any further build upload is rejected by Apple (~20 min in)
with altool 90186 / 90062 — with no signal until then and no guard beyond a
manual 'bump patch' checkbox the dispatcher had to remember to tick.

prepare_release_version now checks the App Store version state via
Spaceship::ConnectAPI before building. If the resolved version's train is
closed it auto-bumps the patch (advancing past any further closed versions),
preserving normal multi-build beta iteration on an open version. An explicit
release_version override that's already closed fails fast with a clear
message instead of being silently overridden.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-28 11:11:18 -07:00
co-authored by Orca
parent 6b1a4497db
commit 603a86fd40
2 changed files with 53 additions and 1 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release'
description: 'Force a marketing version patch bump. Usually unnecessary: a released version auto-bumps. Use this to start a new version while the current one is still open (beta iteration).'
required: false
default: false
type: boolean
+52
View File
@@ -30,6 +30,23 @@ 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" no longer accepts new
# TestFlight build uploads (altool rejects with 90186 "train ... is closed").
# Once a version has been submitted/approved/released, builds must go under a
# higher marketing version. PROCESSING_FOR_APP_STORE / IN_REVIEW are included
# conservatively: a build is already committed to that train.
CLOSED_APP_STORE_STATES = %w[
READY_FOR_SALE
PENDING_DEVELOPER_RELEASE
PENDING_APPLE_RELEASE
PROCESSING_FOR_APP_STORE
IN_REVIEW
WAITING_FOR_REVIEW
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 +88,18 @@ 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_store_connect_api_key (set_spaceship_token: true, the default) has already
# authenticated Spaceship::ConnectAPI globally, so no extra auth is needed here.
def version_train_closed?(version)
app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
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) }
end
def testflight_changelog
changelog = ENV.fetch("TESTFLIGHT_CHANGELOG", "").strip
changelog.empty? ? DEFAULT_TESTFLIGHT_CHANGELOG : changelog
@@ -82,6 +111,29 @@ platform :ios do
api_key = app_store_connect_api_key_from_env
config = load_mobile_app_config
version = resolve_requested_version(options, config)
explicit_version = !options[:version].to_s.strip.empty?
# A closed train (version already submitted/approved/released) cannot accept
# more builds, so Apple would reject the upload (~20 min later) with 90186.
# Auto-bump the patch in the default path; if the caller pinned an exact
# version, fail loudly rather than silently overriding their choice.
if version_train_closed?(version)
if explicit_version
UI.user_error!(
"Requested iOS version #{version} is already submitted/released on the App Store " \
"and cannot accept new builds. Choose a higher release_version.",
)
end
# Advance until we find an open train (handles the rare case where the
# next patch was also already shipped). Bounded to avoid an infinite loop.
original = version
10.times do
version = bump_patch_version(version)
break unless version_train_closed?(version)
end
UI.user_error!("Could not find an open iOS version train after #{original}.") if version_train_closed?(version)
UI.message("iOS version #{original} is closed for new builds; bumping to #{version}.")
end
latest_build_number = latest_testflight_build_number(
api_key: api_key,