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,