diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 0fcba7b8d..b38a79b92 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: '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 diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 9053113d5..a8ccf9b8c 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -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 \" commit.", + ) + end + latest_build_number = latest_testflight_build_number( api_key: api_key, app_identifier: BUNDLE_ID,