
























@@ -5,6 +5,7 @@ require "fileutils"
55require "tmpdir"
66require "tempfile"
77require "cgi"
8+require "digest/md5"
89910default_platform(:ios)
1011@@ -51,6 +52,10 @@ APP_REVIEW_NOTES_METADATA_FILENAMES = [
5152"notes.txt",
5253"review_notes.txt"
5354].freeze
55+APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
56+APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
57+APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
58+APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5
54595560def load_env_file(path)
5661return unless File.exist?(path)
@@ -83,10 +88,6 @@ def release_notes_upload_requested?
8388ENV["DELIVER_RELEASE_NOTES"] == "1"
8489end
859086-def screenshot_paths
87-Dir[File.join(__dir__, "screenshots", "**", "*.png")]
88-end
89-9091def validate_required_screenshots!(paths)
9192missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
9293name unless paths.any? { |path| File.basename(path).match?(pattern) }
@@ -780,6 +781,236 @@ def public_metadata_path
780781temp_root
781782end
782783784+def app_store_screenshot_root
785+File.join(__dir__, "screenshots")
786+end
787+788+def app_store_screenshot_manifest
789+require "deliver/loader"
790+791+Deliver::Loader.load_app_screenshots(app_store_screenshot_root, false)
792+end
793+794+def resolve_app_store_connect_app(app_identifier:, app_id:)
795+require "spaceship"
796+797+app = if env_present?(app_id) && !env_present?(app_identifier)
798+Spaceship::ConnectAPI::App.get(app_id: app_id)
799+else
800+Spaceship::ConnectAPI::App.find(app_identifier || APP_STORE_APP_IDENTIFIER)
801+end
802+UI.user_error!("Could not find App Store Connect app #{app_identifier || app_id || APP_STORE_APP_IDENTIFIER}.") unless app
803+app
804+end
805+806+def resolve_app_store_connect_version(app:, short_version:)
807+version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
808+UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version
809+if version.version_string != short_version
810+UI.user_error!(
811+"Editable App Store Connect version mismatch for #{app.name}: expected #{short_version}, got #{version.version_string}."
812+)
813+end
814+version
815+end
816+817+def app_store_screenshot_sets_for_display_type(localization:, display_type:)
818+localization
819+.get_app_screenshot_sets(includes: "appScreenshots")
820+.select { |set| set.screenshot_display_type == display_type }
821+end
822+823+def clear_app_store_screenshot_sets!(localization:)
824+existing_sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
825+return if existing_sets.empty?
826+827+existing_sets.each do |set|
828+UI.message("Deleting existing #{localization.locale} #{set.screenshot_display_type} screenshot set #{set.id}.")
829+set.delete!
830+end
831+832+deadline = Time.now + APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS
833+loop do
834+sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
835+return if sets.empty?
836+837+if Time.now >= deadline
838+UI.user_error!(
839+"Timed out waiting for App Store Connect to delete #{localization.locale} screenshot sets: #{sets.map(&:id).join(', ')}."
840+)
841+end
842+sleep(3)
843+end
844+end
845+846+def app_store_screenshot_expected_rows(screenshots)
847+screenshots.map do |screenshot|
848+{
849+checksum: Digest::MD5.file(screenshot.path).hexdigest,
850+file_name: File.basename(screenshot.path)
851+}
852+end
853+end
854+855+def app_store_screenshot_actual_rows(app_screenshot_set)
856+(app_screenshot_set.app_screenshots || []).map do |screenshot|
857+{
858+checksum: screenshot.source_file_checksum,
859+file_name: screenshot.file_name,
860+state: (screenshot.asset_delivery_state || {})["state"]
861+}
862+end
863+end
864+865+def format_app_store_screenshot_rows(rows)
866+rows.map do |row|
867+[row[:file_name], row[:checksum], row[:state]].compact.join(" ")
868+end.join(", ")
869+end
870+871+def app_store_screenshot_processing_timeout_seconds
872+raw = ENV["DELIVER_SCREENSHOT_PROCESSING_TIMEOUT"].to_s.strip
873+return APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS if raw.empty?
874+875+unless raw.match?(/\A\d+\z/) && raw.to_i.positive?
876+UI.user_error!("Invalid DELIVER_SCREENSHOT_PROCESSING_TIMEOUT '#{raw}'. Expected a positive number of seconds.")
877+end
878+raw.to_i
879+end
880+881+def app_store_screenshot_state_counts(screenshots)
882+screenshots.each_with_object({}) do |screenshot, counts|
883+state = (screenshot.asset_delivery_state || {})["state"] || "UNKNOWN"
884+counts[state] ||= 0
885+counts[state] += 1
886+end
887+end
888+889+def wait_for_app_store_screenshots_processing!(screenshot_ids:, locale:, display_type:)
890+timeout_seconds = app_store_screenshot_processing_timeout_seconds
891+deadline = Time.now + timeout_seconds
892+loop do
893+screenshots = screenshot_ids.map do |screenshot_id|
894+Spaceship::ConnectAPI.get_app_screenshot(app_screenshot_id: screenshot_id).first
895+end
896+897+failed = screenshots.select(&:error?)
898+unless failed.empty?
899+details = failed.map { |screenshot| "#{screenshot.file_name}: #{screenshot.error_messages.join(', ')}" }
900+UI.user_error!("App Store Connect failed processing #{locale} #{display_type} screenshots: #{details.join('; ')}.")
901+end
902+return screenshots if screenshots.all?(&:complete?)
903+904+if Time.now >= deadline
905+states = app_store_screenshot_state_counts(screenshots)
906+UI.user_error!(
907+"Timed out after #{timeout_seconds}s waiting for App Store Connect to process #{locale} #{display_type} screenshots: #{states}."
908+)
909+end
910+911+UI.verbose("Waiting for #{locale} #{display_type} screenshots to finish processing: #{app_store_screenshot_state_counts(screenshots)}.")
912+sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
913+end
914+end
915+916+def validate_app_store_screenshot_target_counts!(screenshots_by_target)
917+screenshots_by_target.each do |(locale, display_type), screenshots|
918+next if screenshots.length <= APP_STORE_SCREENSHOT_LIMIT_PER_SET
919+920+UI.user_error!(
921+"Found #{screenshots.length} screenshots for #{locale} #{display_type}; App Store Connect allows #{APP_STORE_SCREENSHOT_LIMIT_PER_SET}."
922+)
923+end
924+end
925+926+def verify_app_store_screenshot_set!(app_screenshot_set:, screenshots:, locale:, display_type:)
927+expected = app_store_screenshot_expected_rows(screenshots)
928+actual = app_store_screenshot_actual_rows(app_screenshot_set)
929+actual_identity = actual.map { |row| { checksum: row[:checksum], file_name: row[:file_name] } }
930+incomplete = actual.reject { |row| row[:state] == "COMPLETE" }
931+932+return if actual_identity == expected && incomplete.empty?
933+934+UI.user_error!(
935+"App Store Connect screenshot verification failed for #{locale} #{display_type}. " \
936+"Expected: #{format_app_store_screenshot_rows(expected)}. " \
937+"Actual: #{format_app_store_screenshot_rows(actual)}."
938+)
939+end
940+941+def replace_app_store_screenshot_set!(localization:, display_type:, screenshots:)
942+existing_sets = app_store_screenshot_sets_for_display_type(localization: localization, display_type: display_type)
943+unless existing_sets.empty?
944+UI.user_error!(
945+"App Store Connect still has #{localization.locale} #{display_type} screenshot sets after reset: #{existing_sets.map(&:id).join(', ')}."
946+)
947+end
948+949+UI.message("Creating #{localization.locale} #{display_type} screenshot set.")
950+app_screenshot_set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
951+uploaded_ids = screenshots.map.with_index do |screenshot, index|
952+started_at = Time.now
953+uploaded = app_screenshot_set.upload_screenshot(path: screenshot.path, wait_for_processing: false)
954+UI.message(
955+"Uploaded #{localization.locale} #{display_type} screenshot #{index + 1}/#{screenshots.length}: " \
956+"#{File.basename(screenshot.path)} (#{(Time.now - started_at).round(1)}s)."
957+)
958+uploaded.id
959+end
960+wait_for_app_store_screenshots_processing!(
961+screenshot_ids: uploaded_ids,
962+locale: localization.locale,
963+display_type: display_type
964+)
965+966+app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
967+app_screenshot_set = app_screenshot_set.reorder_screenshots(app_screenshot_ids: uploaded_ids)
968+verify_app_store_screenshot_set!(
969+app_screenshot_set: app_screenshot_set,
970+screenshots: screenshots,
971+locale: localization.locale,
972+display_type: display_type
973+)
974+end
975+976+# Fastlane deliver can duplicate complete screenshots when its verification retry
977+# runs before App Store Connect consistently lists processed assets. Keep the
978+# screenshot write path serial and assert the remote set equals the local files.
979+def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, short_version:, screenshots:)
980+app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
981+version = resolve_app_store_connect_version(app: app, short_version: short_version)
982+localizations_by_locale = version.get_app_store_version_localizations.each_with_object({}) do |localization, index|
983+index[localization.locale] = localization
984+end
985+986+screenshots_by_target = screenshots
987+.sort_by { |screenshot| [screenshot.language.to_s, screenshot.display_type.to_s, File.basename(screenshot.path)] }
988+.group_by { |screenshot| [screenshot.language, screenshot.display_type] }
989+validate_app_store_screenshot_target_counts!(screenshots_by_target)
990+991+missing_locales = screenshots_by_target.keys.map(&:first).uniq.reject { |locale| localizations_by_locale.key?(locale) }
992+unless missing_locales.empty?
993+UI.user_error!(
994+"App Store Connect localizations are missing for screenshot locales #{missing_locales.join(', ')}. " \
995+"Upload metadata for these locales before uploading screenshots."
996+)
997+end
998+999+screenshots_by_target.keys.map(&:first).uniq.each do |locale|
1000+clear_app_store_screenshot_sets!(localization: localizations_by_locale.fetch(locale))
1001+end
1002+1003+screenshots_by_target.each do |(locale, display_type), target_screenshots|
1004+replace_app_store_screenshot_set!(
1005+localization: localizations_by_locale.fetch(locale),
1006+display_type: display_type,
1007+screenshots: target_screenshots
1008+)
1009+end
1010+1011+UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.")
1012+end
1013+7831014def read_ios_version_metadata
7841015script_path = File.join(repo_root, "scripts", "ios-version.ts")
7851016stdout, stderr, status = Open3.capture3(
@@ -1092,11 +1323,11 @@ platform :ios do
10921323app_id = nil unless env_present?(app_id)
1093132410941325if screenshot_upload_requested?
1095-paths = screenshot_paths
1096-if paths.empty?
1326+screenshots_to_upload = app_store_screenshot_manifest
1327+if screenshots_to_upload.empty?
10971328UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
10981329end
1099-validate_required_screenshots!(paths)
1330+validate_required_screenshots!(screenshots_to_upload.map(&:path))
11001331end
1101133211021333assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
@@ -1117,10 +1348,10 @@ platform :ios do
11171348primary_category: "PRODUCTIVITY",
11181349secondary_category: "UTILITIES",
11191350metadata_path: metadata_path,
1120-skip_screenshots: !screenshot_upload_requested?,
1351+skip_screenshots: true,
11211352skip_metadata: skip_metadata,
11221353skip_binary_upload: true,
1123-overwrite_screenshots: screenshot_upload_requested?,
1354+overwrite_screenshots: false,
11241355app_review_attachment_file: app_review_attachment_file,
11251356skip_app_version_update: false,
11261357submit_for_review: false,
@@ -1134,6 +1365,14 @@ platform :ios do
11341365end
1135136611361367deliver(**deliver_options)
1368+if screenshot_upload_requested?
1369+upload_app_store_screenshots_deterministically!(
1370+app_identifier: app_identifier,
1371+app_id: app_id,
1372+short_version: version_metadata[:short_version],
1373+screenshots: screenshots_to_upload
1374+)
1375+end
11371376end
1138137711391378desc "Generate deterministic iOS screenshots for App Store metadata"
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。