fastlane-plugin-fastci 0.0.30 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,67 +1,173 @@
1
1
  require 'fastlane/action'
2
+ require 'json'
3
+ require 'fastlane/plugin/fastci/helper/app_store_resources_helper'
2
4
  include Fastlane::Helper
3
5
 
4
6
  module Fastlane
5
7
  module Actions
8
+ module SharedValues
9
+ FASTCI_STORE_UPLOAD_RESULT = :FASTCI_STORE_UPLOAD_RESULT
10
+ end
11
+
6
12
  # 上传 AppStore
7
13
  class UploadStoreAction < Action
8
14
  def self.run(params)
15
+ # 每次上传重新记录阶段;失败时 package 可区分 IPA 上传与后续资源发布。
16
+ result = { stage: '准备商店上传', ipa_uploaded: false, resources_enabled: false }
17
+ Actions.lane_context[SharedValues::FASTCI_STORE_UPLOAD_RESULT] = result
18
+ resource_params, api_key_options = prepare_upload(params)
19
+ result[:resources_enabled] = !resource_params.nil?
20
+
21
+ if params[:isTestFlight]
22
+ upload_testflight(api_key_options, result)
23
+ else
24
+ upload_app_store(resource_params, api_key_options, result)
25
+ end
26
+ end
27
+
28
+ # 上传前统一准备认证和资源,确保配置错误或资源缺失在 IPA 上传前被发现。
29
+ def self.prepare_upload(params)
30
+ resource_params = load_store_resources(params)
31
+ api_key_options = AppStoreResourcesHelper.authentication_options(resource_params || {}, other_action)
32
+ if resource_params
33
+ resource_params.merge!(api_key_options)
34
+ resource_params = AppStoreResourcesHelper.prepare(resource_params)
35
+ end
36
+ [resource_params, api_key_options]
37
+ end
9
38
 
10
- release_notes = JSON.parse(params[:release_notes] || "") rescue nil
39
+ # 只有显式开启的 App Store 流程读取资源配置,关闭时不解析路径或更新说明。
40
+ def self.load_store_resources(params)
41
+ config_path = AppStoreResourcesHelper.upload_config_path(params, app_store: !params[:isTestFlight])
42
+ return nil unless config_path
11
43
 
12
- other_action.app_store_connect_api_key(
13
- key_id: Environment.connect_key_id,
14
- issuer_id: Environment.connect_issuer_id,
15
- key_filepath: File.expand_path("#{Environment.package_file_folder_name}/AuthKey_#{Environment.connect_key_id}.p8"),
16
- duration: 1200, # optional (maximum 1200)
17
- in_house: false # optional but may be required if using match/sigh
44
+ AppStoreResourcesHelper.load_params(
45
+ app_store_resources_enabled: true,
46
+ config_path: config_path,
47
+ app_identifier: params[:app_identifier],
48
+ app_version: params[:app_version],
49
+ build_number: params[:build_number],
50
+ release_notes: parse_release_notes(params[:release_notes])
18
51
  )
52
+ end
19
53
 
20
- if params[:isTestFlight]
21
- UI.message("*************| 开始上传 TestFlight |*************")
22
- other_action.upload_to_testflight(
23
- skip_waiting_for_build_processing: true
24
- )
25
- else
26
- UI.message("*************| 开始上传 AppStore |*************")
27
-
28
- # 构建上传参数,只有当 release_notes 有效时才添加
29
- upload_options = {
30
- skip_metadata: false,
31
- skip_screenshots: true,
32
- run_precheck_before_submit: false,
33
- precheck_include_in_app_purchases: false,
34
- force: true,
35
- submit_for_review: false,
36
- automatic_release: false
37
- }
38
-
39
- # 只有当 release_notes 不为 nil 且不为空时才添加
40
- if release_notes && !release_notes.empty?
41
- upload_options[:release_notes] = release_notes
42
- end
43
-
44
- other_action.upload_to_app_store(upload_options)
54
+ # 保留原 JSON 字符串接口;空值或无效 JSON 不覆盖资源文件中的更新说明。
55
+ def self.parse_release_notes(value)
56
+ JSON.parse(value || '')
57
+ rescue JSON::ParserError, TypeError
58
+ nil
59
+ end
60
+
61
+ # TestFlight 沿用现有上传方式,不执行 App Store 资源流程。
62
+ def self.upload_testflight(api_key_options, result)
63
+ UI.message('*************| 开始上传 TestFlight |*************')
64
+ result[:stage] = '上传 TestFlight IPA'
65
+ upload_result = other_action.upload_to_testflight({ skip_waiting_for_build_processing: true }.merge(api_key_options))
66
+ result[:ipa_uploaded] = true
67
+ upload_result
68
+ end
69
+
70
+ # 先上传 IPA,再处理已校验的资源;返回 IPA 上传结果以兼容已有 lane。
71
+ def self.upload_app_store(resource_params, api_key_options, result)
72
+ UI.message('*************| 开始上传 AppStore |*************')
73
+ result[:stage] = '上传 App Store IPA'
74
+ upload_options = app_store_upload_options(resource_params).merge(api_key_options)
75
+ upload_result = other_action.upload_to_app_store(upload_options)
76
+ result[:ipa_uploaded] = true
77
+ if resource_params
78
+ result[:stage] = '发布商店资源 / 选择构建 / 提交审核'
79
+ # 新版本判断已在 IPA 上传前完成,之后直接使用确认过的资源上传开关。
80
+ other_action.app_store_resources(resource_params.merge(upload_metadata_on_new_version: false))
81
+ record_resource_result(result, resource_params)
82
+ end
83
+ upload_result
84
+ end
85
+
86
+ # 仅在整个资源 Action 成功返回后记录完成项,保持原 Action 的返回值不变。
87
+ def self.record_resource_result(result, params)
88
+ result.merge!(
89
+ metadata_uploaded: params[:metadata_changed],
90
+ screenshots_uploaded: params[:screenshots_changed],
91
+ build_selected: params[:select_build] || params[:submit_for_review],
92
+ review_submitted: params[:submit_for_review]
93
+ )
94
+ end
95
+
96
+ # IPA 阶段始终关闭资源上传,避免默认目录或 Deliverfile 绕过资源总开关。
97
+ def self.app_store_upload_options(resource_params)
98
+ options = {
99
+ skip_metadata: true,
100
+ skip_screenshots: true,
101
+ # deliver 的截图同步分支不受 skip_screenshots 控制,必须一并关闭。
102
+ sync_screenshots: false,
103
+ overwrite_screenshots: false,
104
+ run_precheck_before_submit: false,
105
+ precheck_include_in_app_purchases: false,
106
+ force: true,
107
+ submit_for_review: false,
108
+ automatic_release: false
109
+ }
110
+ if resource_params
111
+ options[:app_identifier] = resource_params[:app_identifier]
112
+ options[:app_version] = resource_params[:app_version] if resource_params[:app_version]
45
113
  end
114
+ options
46
115
  end
47
116
 
117
+ private_class_method :prepare_upload, :load_store_resources, :parse_release_notes,
118
+ :upload_testflight, :upload_app_store, :app_store_upload_options, :record_resource_result
119
+
48
120
  def self.description
49
121
  "上传 AppStore"
50
122
  end
51
123
 
52
124
  def self.available_options
53
125
  [
126
+ FastlaneCore::ConfigItem.new(
127
+ key: :app_store_resources_enabled,
128
+ env_name: 'APP_STORE_RESOURCES_ENABLED',
129
+ description: '商店资源总开关,默认关闭;关闭时仅上传 IPA,不更新元数据或截图',
130
+ optional: true,
131
+ default_value: false,
132
+ type: Boolean
133
+ ),
54
134
  FastlaneCore::ConfigItem.new(
55
135
  key: :release_notes,
56
136
  description: "更新文案, 格式为 { \"zh-Hans\": \"修复问题\", \"en-US\": \"bugfix\"} JSON 字符串",
57
- optional: false,
137
+ optional: true,
58
138
  type: String
59
139
  ),
60
140
  FastlaneCore::ConfigItem.new(
61
141
  key: :isTestFlight,
62
142
  description: "是否为 TestFlight 打包",
63
- optional: false,
143
+ optional: true,
144
+ default_value: false,
64
145
  type: Boolean
146
+ ),
147
+ FastlaneCore::ConfigItem.new(
148
+ key: :app_store_resources_config_path,
149
+ env_name: 'APP_STORE_RESOURCES_CONFIG_PATH',
150
+ description: 'App Store 资源发布 JSON 路径,开启商店资源总开关后必填',
151
+ optional: true,
152
+ type: String
153
+ ),
154
+ FastlaneCore::ConfigItem.new(
155
+ key: :app_identifier,
156
+ description: '本次构建的 Bundle ID,覆盖资源配置',
157
+ optional: true,
158
+ type: String
159
+ ),
160
+ FastlaneCore::ConfigItem.new(
161
+ key: :app_version,
162
+ description: '本次构建的版本号,覆盖资源配置',
163
+ optional: true,
164
+ type: String
165
+ ),
166
+ FastlaneCore::ConfigItem.new(
167
+ key: :build_number,
168
+ description: '本次构建的 Build 号,覆盖资源配置',
169
+ optional: true,
170
+ type: String
65
171
  )
66
172
  ]
67
173
  end
@@ -72,4 +178,4 @@ module Fastlane
72
178
 
73
179
  end
74
180
  end
75
- end
181
+ end
@@ -0,0 +1,436 @@
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'timeout'
4
+
5
+ module Fastlane
6
+ module Helper
7
+ # App Store Connect 资源发布辅助方法
8
+ class AppStoreResourcesHelper
9
+ # deliver 使用按语言分目录的文本文件保存商店文案及审核信息。
10
+ METADATA_FILE_PATTERN = '*.txt'
11
+
12
+ # 只接受通用发布字段,拼写错误或业务项目的嵌套配置应直接报错。
13
+ # 总开关由环境变量或 Action 参数控制,不允许资源 JSON 自行开启功能。
14
+ CONFIG_KEYS = %i[
15
+ app_identifier
16
+ app_version
17
+ metadata_path
18
+ screenshots_path
19
+ metadata_changed
20
+ screenshots_changed
21
+ upload_metadata_on_new_version
22
+ download_missing_metadata
23
+ download_missing_screenshots
24
+ use_live_version
25
+ api_key
26
+ api_key_path
27
+ username
28
+ platform
29
+ sync_screenshots
30
+ overwrite_screenshots
31
+ submit_for_review
32
+ automatic_release
33
+ select_build
34
+ build_number
35
+ wait_for_build_processing
36
+ build_processing_timeout
37
+ build_processing_poll_interval
38
+ build_processing_retry_limit
39
+ build_processing_retry_interval
40
+ submission_information
41
+ release_notes
42
+ force
43
+ ].freeze
44
+
45
+ # 同时支持普通 Hash 和 Fastlane Configuration;默认值与 Action 参数声明保持一致。
46
+ # 总开关开启后默认只更新元数据,截图替换、构建选择和审核发布仍需配置开启。
47
+ DEFAULT_PARAMS = {
48
+ metadata_path: 'fastlane/metadata',
49
+ screenshots_path: 'fastlane/screenshots',
50
+ metadata_changed: true,
51
+ screenshots_changed: false,
52
+ upload_metadata_on_new_version: true,
53
+ download_missing_metadata: false,
54
+ download_missing_screenshots: false,
55
+ use_live_version: false,
56
+ platform: 'ios',
57
+ sync_screenshots: false,
58
+ overwrite_screenshots: false,
59
+ submit_for_review: false,
60
+ automatic_release: false,
61
+ select_build: false,
62
+ wait_for_build_processing: true,
63
+ build_processing_timeout: 3600,
64
+ build_processing_poll_interval: 30,
65
+ build_processing_retry_limit: 3,
66
+ build_processing_retry_interval: 15,
67
+ force: true
68
+ }.freeze
69
+
70
+ # 打包和 IPA 上传共用的资源入口校验;关闭总开关或非 App Store 时不读取路径。
71
+ def self.upload_config_path(params, app_store:)
72
+ return nil unless app_store && params[:app_store_resources_enabled] == true
73
+
74
+ path = params[:app_store_resources_config_path]
75
+ UI.user_error!('开启商店资源功能后必须配置 APP_STORE_RESOURCES_CONFIG_PATH') if path.to_s.strip.empty?
76
+ path
77
+ end
78
+
79
+ # 合并并校验配置,优先级为显式参数 > JSON > 默认值,返回规范化后的 Hash。
80
+ def self.load_params(params)
81
+ config_path = params[:config_path]
82
+ config = {}
83
+ unless config_path.to_s.strip.empty?
84
+ config_path = File.expand_path(config_path)
85
+ UI.user_error!("资源配置文件不存在: #{config_path}") unless File.file?(config_path)
86
+ config = JSON.parse(File.read(config_path))
87
+ UI.user_error!("资源配置文件必须是扁平 JSON 对象: #{config_path}") unless config.is_a?(Hash)
88
+ end
89
+
90
+ config = config.each_with_object({}) do |(key, value), values|
91
+ symbol_key = key.to_sym
92
+ UI.user_error!("资源配置包含不支持的字段: #{key}") unless CONFIG_KEYS.include?(symbol_key)
93
+ values[symbol_key] = value
94
+ end
95
+
96
+ # _values 只包含调用者传入的值,避免 Action 默认值覆盖 JSON 中的 false。
97
+ explicit_values = if params.respond_to?(:_values)
98
+ params._values
99
+ else
100
+ params
101
+ end
102
+ explicit_values = explicit_values.reject { |_key, value| value.nil? }
103
+ values = DEFAULT_PARAMS.merge(config).merge(explicit_values)
104
+ values[:config_path] = config_path unless config_path.to_s.strip.empty?
105
+ values[:app_identifier] ||= Environment.bundleID
106
+ # JSON 同样经过 Fastlane 的类型、冲突和路径校验,避免字符串 false 被当成开启。
107
+ configuration = FastlaneCore::Configuration.create(Actions::AppStoreResourcesAction.available_options, values)
108
+ configuration.values(ask: false).tap do |resolved|
109
+ UI.user_error!('必须配置 App Store Connect App 的 Bundle ID') if resolved[:app_identifier].to_s.strip.empty?
110
+ validate_build_params(resolved) if resolved[:select_build] || resolved[:submit_for_review]
111
+ end
112
+ rescue JSON::ParserError
113
+ # 解析器的错误详情可能带出原始 JSON,其中可能包含认证信息。
114
+ UI.user_error!("资源配置文件不是有效 JSON: #{config_path}")
115
+ end
116
+
117
+ # 复用项目的认证配置;JSON 或显式参数优先于环境变量和已有 lane 凭据。
118
+ def self.authentication_options(params, other_action)
119
+ return { api_key: params[:api_key] } if params[:api_key]
120
+ return { api_key_path: params[:api_key_path] } if params[:api_key_path]
121
+ return { username: params[:username] } if params[:username]
122
+
123
+ path = Environment.app_store_connect_api_key_path
124
+ unless path.empty?
125
+ UI.user_error!("API Key JSON 不存在: #{path}") unless File.file?(path)
126
+ return { api_key_path: File.expand_path(path) }
127
+ end
128
+ # 同一 lane 已创建 API Key 时直接复用,避免重复读取 P8 或改用其他凭据。
129
+ api_key = Actions.lane_context[Actions::SharedValues::APP_STORE_CONNECT_API_KEY]
130
+ return { api_key: api_key } if api_key
131
+
132
+ if Environment.connect_key_id.to_s.strip.empty? || Environment.connect_issuer_id.to_s.strip.empty?
133
+ UI.user_error!('请配置 APP_STORE_CONNECT_API_KEY_PATH 或 CONNECT_KEY_ID / CONNECT_ISSUER_ID')
134
+ end
135
+ { api_key: other_action.app_store_connect_api_key(
136
+ key_id: Environment.connect_key_id,
137
+ issuer_id: Environment.connect_issuer_id,
138
+ key_filepath: File.expand_path(Environment.app_store_connect_p8_path),
139
+ duration: 1200,
140
+ in_house: false
141
+ ) }
142
+ end
143
+
144
+ # 在上传 IPA 前检查目标版本并校验资源;独立调用时复用同一流程。
145
+ def self.prepare(params)
146
+ params = params.dup
147
+ if metadata_required_for_new_version?(params)
148
+ UI.message("目标 App Store 版本 #{params[:app_version]} 不存在,将上传 metadata")
149
+ params[:metadata_changed] = true
150
+ end
151
+ %i[metadata screenshots].each do |kind|
152
+ path_key = :"#{kind}_path"
153
+ download = params[:"download_missing_#{kind}"]
154
+ params[path_key] = resource_path(
155
+ params[path_key], "#{kind} 目录", create: download,
156
+ required: params[:"#{kind}_changed"] || download
157
+ )
158
+ end
159
+ # 首次接入可先下载资源,再校验待上传目录;关闭的资源类别不要求目录存在。
160
+ download_missing(params, params[:metadata_path], params[:screenshots_path])
161
+ validate_metadata(params[:metadata_path]) if params[:metadata_changed]
162
+ validate_screenshots(params[:screenshots_path]) if params[:screenshots_changed]
163
+ params
164
+ end
165
+
166
+ # 相对路径基于 Action 执行目录解析;仅初始化下载时创建目录。
167
+ def self.resource_path(path, label, create: false, required: true)
168
+ UI.user_error!("#{label} 不能为空") if path.to_s.strip.empty?
169
+ resource_path = File.expand_path(path.to_s)
170
+ FileUtils.mkdir_p(resource_path) if create
171
+ UI.user_error!("#{label} 不存在: #{resource_path}") if required && !File.directory?(resource_path)
172
+
173
+ resource_path
174
+ end
175
+
176
+ # 递归查找各语言及 review_information 下的文本资源。
177
+ def self.metadata_files(path)
178
+ Dir[File.join(path, '**', METADATA_FILE_PATTERN)].select { |file| File.file?(file) }
179
+ end
180
+
181
+ # 仅将支持的图片扩展名计入资源,避免 README 等文件阻止首次下载。
182
+ def self.screenshot_files(path)
183
+ Dir[File.join(path, '**', '*')].select do |file|
184
+ File.file?(file) && %w[.png .jpg .jpeg].include?(File.extname(file).downcase)
185
+ end
186
+ end
187
+
188
+ # 上传开关开启时拒绝空目录,避免将缺失资源当成发布成功。
189
+ def self.validate_metadata(path)
190
+ UI.user_error!("metadata 目录为空: #{path}") if metadata_files(path).empty?
191
+ end
192
+
193
+ # 此处检查截图是否存在,图片格式及设备尺寸由 deliver 校验。
194
+ def self.validate_screenshots(path)
195
+ UI.user_error!("截图目录为空: #{path}") if screenshot_files(path).empty?
196
+ end
197
+
198
+ # 资源上传与 IPA、审核分开执行,防止重复上传构建或提前提交审核。
199
+ def self.resource_options(params, metadata_path, screenshots_path)
200
+ {
201
+ app_identifier: params[:app_identifier],
202
+ app_version: params[:app_version],
203
+ metadata_path: metadata_path,
204
+ screenshots_path: screenshots_path,
205
+ skip_binary_upload: true,
206
+ skip_metadata: !params[:metadata_changed],
207
+ skip_screenshots: !params[:screenshots_changed],
208
+ # deliver 的同步分支会绕过 skip_screenshots,必须跟随截图上传开关关闭。
209
+ sync_screenshots: params[:screenshots_changed] && params[:sync_screenshots],
210
+ overwrite_screenshots: params[:screenshots_changed] && params[:overwrite_screenshots],
211
+ submit_for_review: false,
212
+ automatic_release: false,
213
+ run_precheck_before_submit: false,
214
+ precheck_include_in_app_purchases: false,
215
+ force: params[:force]
216
+ }.tap do |options|
217
+ options[:api_key] = params[:api_key] if params[:api_key]
218
+ options[:api_key_path] = params[:api_key_path] if params[:api_key_path]
219
+ options[:username] = params[:username] if params[:username]
220
+ options[:platform] = params[:platform] if params[:platform]
221
+ options[:release_notes] = params[:release_notes] if params[:metadata_changed] && params[:release_notes]
222
+ end
223
+ end
224
+
225
+ # 在 IPA 上传前判断目标版本是否已经存在,避免新版本漏传 metadata。
226
+ def self.metadata_required_for_new_version?(params)
227
+ return false unless params[:upload_metadata_on_new_version] && !params[:app_version].to_s.empty?
228
+
229
+ authenticate(params)
230
+ app = Spaceship::ConnectAPI::App.find(params[:app_identifier])
231
+ UI.user_error!("找不到 App Store Connect 应用: #{params[:app_identifier]}") unless app
232
+
233
+ platform = Spaceship::ConnectAPI::Platform.map(params[:platform])
234
+ version = app.get_edit_app_store_version(platform: platform)
235
+ version.nil? || version.version_string != params[:app_version].to_s
236
+ end
237
+
238
+ # 下载只初始化 deliver 的应用和认证上下文,关闭所有上传选项。
239
+ def self.download_options(params, metadata_path, screenshots_path)
240
+ resource_options(params, metadata_path, screenshots_path).merge(
241
+ skip_binary_upload: true,
242
+ skip_metadata: true,
243
+ skip_screenshots: true,
244
+ sync_screenshots: false,
245
+ overwrite_screenshots: false,
246
+ use_live_version: params[:use_live_version]
247
+ )
248
+ end
249
+
250
+ # 仅为空的资源类别下载基线,保留本地已有文案和截图。
251
+ def self.download_missing(params, metadata_path, screenshots_path)
252
+ download_metadata_enabled = params[:download_missing_metadata] && metadata_files(metadata_path).empty?
253
+ download_screenshots_enabled = params[:download_missing_screenshots] && screenshot_files(screenshots_path).empty?
254
+ return unless download_metadata_enabled || download_screenshots_enabled
255
+
256
+ require 'deliver'
257
+ require 'deliver/setup'
258
+ require 'deliver/download_screenshots'
259
+
260
+ options = FastlaneCore::Configuration.create(
261
+ Deliver::Options.available_options,
262
+ download_options(params, metadata_path, screenshots_path)
263
+ )
264
+ # 仅初始化 Runner,不调用 run,防止下载流程创建版本或触发上传。
265
+ Deliver::Runner.new(options, skip_version: true)
266
+ app = Deliver.cache[:app]
267
+ platform = Spaceship::ConnectAPI::Platform.map(options[:platform])
268
+
269
+ download_metadata(app, platform, options, metadata_path) if download_metadata_enabled
270
+ download_screenshots(options, screenshots_path) if download_screenshots_enabled
271
+ end
272
+
273
+ # 最新版本与目标版本不符时使用线上版本作为文案基线,新版本可据此修改。
274
+ def self.download_metadata(app, platform, options, metadata_path)
275
+ version = app.get_latest_app_store_version(platform: platform)
276
+ if options[:app_version].to_s.length > 0 && version&.version_string != options[:app_version]
277
+ version = app.get_live_app_store_version(platform: platform)
278
+ end
279
+ UI.user_error!('App Store Connect 没有可下载的版本') unless version
280
+
281
+ Deliver::Setup.new.generate_metadata_files(app, version, metadata_path, options)
282
+ end
283
+
284
+ # 交由 deliver 按 use_live_version 选择版本,并保留其语言与设备目录结构。
285
+ def self.download_screenshots(options, screenshots_path)
286
+ Deliver::DownloadScreenshots.download(options, screenshots_path)
287
+ end
288
+
289
+ # 仅绑定已处理成功且版本匹配的精确构建,防止同版本多个 Build 时选错包。
290
+ def self.select_build(params)
291
+ validate_build_params(params)
292
+ authenticate(params)
293
+
294
+ app = Spaceship::ConnectAPI::App.find(params[:app_identifier])
295
+ UI.user_error!("找不到 App Store Connect 应用: #{params[:app_identifier]}") unless app
296
+
297
+ platform = Spaceship::ConnectAPI::Platform.map(params[:platform])
298
+ build = find_build(params, app, platform)
299
+ valid_state = Spaceship::ConnectAPI::Build::ProcessingState::VALID
300
+ unless build.processing_state == valid_state
301
+ UI.user_error!(
302
+ "App Store Build #{params[:app_version]} (#{params[:build_number]}) 处理失败,状态: #{build.processing_state}"
303
+ )
304
+ end
305
+
306
+ version = app.get_edit_app_store_version(platform: platform)
307
+ UI.user_error!("找不到可编辑的 App Store 版本: #{params[:app_version]}") unless version
308
+ unless version.version_string == params[:app_version].to_s
309
+ UI.user_error!(
310
+ "可编辑版本不匹配,期望 #{params[:app_version]},实际 #{version.version_string}"
311
+ )
312
+ end
313
+
314
+ version.select_build(build_id: build.id)
315
+ UI.success("已自动选择 App Store 构建: #{params[:app_version]} (#{params[:build_number]})")
316
+ build
317
+ end
318
+
319
+ # 资源上传、构建选择成功后才使用此配置提交审核,不重复上传任何资源。
320
+ def self.review_options(params)
321
+ {
322
+ app_identifier: params[:app_identifier],
323
+ app_version: params[:app_version],
324
+ build_number: params[:build_number].to_s,
325
+ platform: params[:platform],
326
+ skip_binary_upload: true,
327
+ skip_metadata: true,
328
+ skip_screenshots: true,
329
+ sync_screenshots: false,
330
+ overwrite_screenshots: false,
331
+ submit_for_review: true,
332
+ automatic_release: params[:automatic_release],
333
+ run_precheck_before_submit: false,
334
+ precheck_include_in_app_purchases: false,
335
+ force: params[:force]
336
+ }.tap do |options|
337
+ options[:api_key] = params[:api_key] if params[:api_key]
338
+ options[:api_key_path] = params[:api_key_path] if params[:api_key_path]
339
+ options[:username] = params[:username] if params[:username]
340
+ options[:submission_information] = params[:submission_information] if params[:submission_information]
341
+ end
342
+ end
343
+
344
+ # 构建选择和审核提交必须指明版本与 Build,不允许回退到远端最新构建。
345
+ def self.validate_build_params(params)
346
+ UI.user_error!('自动选择构建或提交审核时必须配置 app_version') if params[:app_version].to_s.empty?
347
+ UI.user_error!('自动选择构建或提交审核时必须配置 build_number') if params[:build_number].to_s.empty?
348
+ end
349
+
350
+ # 为直接调用 Spaceship 的版本查询和构建选择建立会话;优先使用 API Token。
351
+ def self.authenticate(params)
352
+ require 'spaceship/connect_api'
353
+
354
+ token = Spaceship::ConnectAPI::Token.from(
355
+ hash: params[:api_key],
356
+ filepath: params[:api_key_path]
357
+ )
358
+ Spaceship::ConnectAPI.token = token if token
359
+ return if Spaceship::ConnectAPI.token
360
+
361
+ username = params[:username].to_s
362
+ UI.user_error!('App Store 资源操作需要 API Key 或 username') if username.empty?
363
+
364
+ Spaceship::ConnectAPI.login(username, nil, use_portal: false, use_tunes: true)
365
+ end
366
+
367
+ # 根据配置等待处理完成或只查询一次,两条路径都同时限定版本号与 Build 号。
368
+ def self.find_build(params, app, platform)
369
+ if params[:wait_for_build_processing]
370
+ return wait_for_build_processing(params, app, platform, params[:build_number])
371
+ end
372
+
373
+ build = Spaceship::ConnectAPI::Build.all(
374
+ app_id: app.id,
375
+ version: params[:app_version].to_s,
376
+ build_number: params[:build_number].to_s,
377
+ platform: platform
378
+ ).first
379
+ UI.user_error!(
380
+ "找不到 App Store Build: #{params[:app_version]} (#{params[:build_number]})"
381
+ ) unless build
382
+ build
383
+ end
384
+
385
+ # App Store Connect 偶发中断 HTTPS 连接时,在原处理超时范围内重试查询。
386
+ def self.wait_for_build_processing(params, app, platform, build_number)
387
+ require 'fastlane_core/build_watcher'
388
+
389
+ timeout = params[:build_processing_timeout].to_i
390
+ deadline = Time.now + timeout
391
+ retry_limit = params[:build_processing_retry_limit].to_i
392
+ retry_interval = params[:build_processing_retry_interval].to_i
393
+ retry_count = 0
394
+
395
+ begin
396
+ # 网络重试共享最初的截止时间,不因重试次数增加而重新计时。
397
+ remaining_timeout = [(deadline - Time.now).ceil, 1].max
398
+ return FastlaneCore::BuildWatcher.wait_for_build_processing_to_be_complete(
399
+ app_id: app.id,
400
+ app_version: params[:app_version].to_s,
401
+ build_version: build_number.to_s,
402
+ platform: platform,
403
+ poll_interval: params[:build_processing_poll_interval].to_i,
404
+ timeout_duration: remaining_timeout,
405
+ return_when_build_appears: false,
406
+ return_spaceship_testflight_build: false,
407
+ select_latest: false
408
+ )
409
+ rescue StandardError => error
410
+ raise unless retryable_build_processing_error?(error)
411
+ raise if retry_count >= retry_limit || Time.now >= deadline
412
+
413
+ retry_count += 1
414
+ wait_seconds = [retry_interval, [(deadline - Time.now).floor, 0].max].min
415
+ UI.important(
416
+ "App Store Connect 查询构建时网络异常,将在 #{wait_seconds} 秒后第 #{retry_count}/#{retry_limit} 次重试: #{error.message}"
417
+ )
418
+ sleep(wait_seconds) if wait_seconds.positive?
419
+ retry
420
+ end
421
+ end
422
+
423
+ # 仅重试临时连接异常,认证失败、构建无效等业务错误保持立即失败。
424
+ def self.retryable_build_processing_error?(error)
425
+ return true if error.is_a?(EOFError) || error.is_a?(SocketError) || error.is_a?(Timeout::Error)
426
+
427
+ error.message.match?(
428
+ /(SSL_connect|unexpected eof|connection (?:reset|aborted|failed)|execution expired|timed out|read timeout|socket error)/i
429
+ )
430
+ end
431
+
432
+ private_class_method :download_metadata, :download_screenshots, :authenticate, :find_build,
433
+ :wait_for_build_processing, :retryable_build_processing_error?
434
+ end
435
+ end
436
+ end