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.
- checksums.yaml +4 -4
- data/README.md +380 -11
- data/lib/fastlane/plugin/fastci/actions/app_store_resources_action.rb +300 -0
- data/lib/fastlane/plugin/fastci/actions/package_action.rb +129 -124
- data/lib/fastlane/plugin/fastci/actions/upload_store_action.rb +141 -35
- data/lib/fastlane/plugin/fastci/helper/app_store_resources_helper.rb +436 -0
- data/lib/fastlane/plugin/fastci/helper/dingding_helper.rb +57 -22
- data/lib/fastlane/plugin/fastci/helper/dingding_image_helper.rb +70 -0
- data/lib/fastlane/plugin/fastci/helper/environment.rb +26 -0
- data/lib/fastlane/plugin/fastci/helper/package_notification_helper.rb +184 -0
- data/lib/fastlane/plugin/fastci/version.rb +1 -1
- metadata +6 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
137
|
+
optional: true,
|
|
58
138
|
type: String
|
|
59
139
|
),
|
|
60
140
|
FastlaneCore::ConfigItem.new(
|
|
61
141
|
key: :isTestFlight,
|
|
62
142
|
description: "是否为 TestFlight 打包",
|
|
63
|
-
optional:
|
|
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
|