From 603a86fd40c2ea58d04bd8097517b3ea65ad06d8 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Sun, 28 Jun 2026 11:11:18 -0700 Subject: [PATCH] fix(mobile): auto-bump iOS version when App Store train is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/mobile-ios-release.yml | 2 +- mobile/fastlane/Fastfile | 52 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 0fcba7b8d5b..2b3f4133790 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -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 diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 9053113d565..7ca9c61de65 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -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,