fastlane-plugin-rustore_publish 0.3.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +445 -0
- data/lib/fastlane/plugin/rustore_publish/actions/rustore_upload.rb +523 -0
- data/lib/fastlane/plugin/rustore_publish/certs/russian_trusted_root_ca.crt +33 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_auth.rb +179 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_client.rb +353 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_endpoint.rb +33 -0
- data/lib/fastlane/plugin/rustore_publish/helper/rustore_logger.rb +108 -0
- data/lib/fastlane/plugin/rustore_publish/version.rb +5 -0
- data/lib/fastlane/plugin/rustore_publish.rb +13 -0
- metadata +144 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
require "fastlane/action"
|
|
2
|
+
require_relative "../helper/rustore_auth"
|
|
3
|
+
require_relative "../helper/rustore_client"
|
|
4
|
+
require_relative "../helper/rustore_logger"
|
|
5
|
+
|
|
6
|
+
module Fastlane
|
|
7
|
+
module Actions
|
|
8
|
+
class RustoreUploadAction < Action
|
|
9
|
+
TOTAL_STEPS = 5
|
|
10
|
+
|
|
11
|
+
def self.run(params)
|
|
12
|
+
logger = RustorePublish::RustoreLogger.new(total_steps: TOTAL_STEPS)
|
|
13
|
+
|
|
14
|
+
# ── Step 1: Authentication ─────────────────────────────────────────────
|
|
15
|
+
logger.step(1, "Authentication")
|
|
16
|
+
|
|
17
|
+
private_key = params[:private_key] || File.read(params[:private_key_path])
|
|
18
|
+
auth = RustorePublish::RustoreAuth.new(
|
|
19
|
+
key_id: params[:key_id],
|
|
20
|
+
private_key: private_key,
|
|
21
|
+
logger: logger
|
|
22
|
+
)
|
|
23
|
+
auth.refresh!
|
|
24
|
+
logger.success("JWE token obtained (valid 900s, auto-refresh enabled)")
|
|
25
|
+
|
|
26
|
+
client = RustorePublish::RustoreClient.new(auth: auth, logger: logger, upload_timeout: params[:upload_timeout])
|
|
27
|
+
package_name = params[:package_name]
|
|
28
|
+
|
|
29
|
+
# ── Step 2: Draft Management ───────────────────────────────────────────
|
|
30
|
+
logger.step(2, "Draft Management")
|
|
31
|
+
|
|
32
|
+
existing = client.find_existing_draft(package_name: package_name)
|
|
33
|
+
if existing
|
|
34
|
+
draft_id = existing["publishId"] || existing["versionId"]
|
|
35
|
+
logger.warning("Found existing draft (id=#{draft_id}) — deleting it")
|
|
36
|
+
client.delete_draft(package_name: package_name, version_id: draft_id)
|
|
37
|
+
logger.info("Existing draft deleted")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
whats_new = params[:whats_new]
|
|
41
|
+
moder_info = params[:moder_info]
|
|
42
|
+
publish_type = params[:publish_type]
|
|
43
|
+
release_date = params[:release_date]
|
|
44
|
+
rollout_percentage = params[:rollout_percentage]
|
|
45
|
+
|
|
46
|
+
if release_date && publish_type != "DELAYED"
|
|
47
|
+
logger.warning("release_date is set but publish_type is '#{publish_type}' — only used with DELAYED")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
draft_meta = {}
|
|
51
|
+
draft_meta[:whatsNew] = whats_new if whats_new
|
|
52
|
+
draft_meta[:moderInfo] = moder_info if moder_info
|
|
53
|
+
draft_meta[:publishType] = publish_type
|
|
54
|
+
draft_meta[:publishDateTime] = release_date if release_date
|
|
55
|
+
draft_meta[:partialValue] = rollout_percentage if rollout_percentage
|
|
56
|
+
|
|
57
|
+
# Draft metadata (RuStore API v2)
|
|
58
|
+
developer_contacts = params[:developer_contacts]
|
|
59
|
+
draft_meta[:developerContacts] = normalize_developer_contacts(developer_contacts) if developer_contacts
|
|
60
|
+
draft_meta[:appName] = params[:app_name] if params[:app_name]
|
|
61
|
+
draft_meta[:appType] = params[:app_type] if params[:app_type]
|
|
62
|
+
draft_meta[:ageLegal] = params[:age_legal] if params[:age_legal]
|
|
63
|
+
draft_meta[:shortDescription] = params[:short_description] if params[:short_description]
|
|
64
|
+
draft_meta[:fullDescription] = params[:full_description] if params[:full_description]
|
|
65
|
+
draft_meta[:priceValue] = params[:price_value] if params[:price_value]
|
|
66
|
+
draft_meta[:categories] = params[:categories] if params[:categories]
|
|
67
|
+
draft_meta[:seoTagIds] = params[:seo_tag_ids] if params[:seo_tag_ids]
|
|
68
|
+
draft_meta[:faq] = params[:faq] if params[:faq]
|
|
69
|
+
|
|
70
|
+
version_id = client.create_draft(package_name: package_name, **draft_meta)
|
|
71
|
+
logger.success("Draft created (versionId=#{version_id})")
|
|
72
|
+
logger.table([
|
|
73
|
+
["Package", package_name],
|
|
74
|
+
["Version ID", version_id],
|
|
75
|
+
["What's new", whats_new || "(not set)"],
|
|
76
|
+
["Moderator note", moder_info || "(not set)"],
|
|
77
|
+
["Publish type", publish_type],
|
|
78
|
+
["Rollout", rollout_percentage ? "#{rollout_percentage}%" : "100%"],
|
|
79
|
+
["Release date", release_date || "(not set)"],
|
|
80
|
+
["Update priority", params[:update_priority]],
|
|
81
|
+
["Upload timeout", "#{params[:upload_timeout]}s"]
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
# ── Step 3: Upload primary build ───────────────────────────────────────
|
|
85
|
+
aab_path = params[:aab_path]
|
|
86
|
+
apk_path = params[:apk_path]
|
|
87
|
+
hms_apk_path = params[:hms_apk_path]
|
|
88
|
+
|
|
89
|
+
primary_label = aab_path ? "Uploading AAB (GMS/main)" : "Uploading APK (main)"
|
|
90
|
+
logger.step(3, primary_label)
|
|
91
|
+
|
|
92
|
+
if aab_path
|
|
93
|
+
validate_file!(aab_path, "aab_path", ".aab", logger)
|
|
94
|
+
logger.table([
|
|
95
|
+
["File", File.basename(aab_path)],
|
|
96
|
+
["Size", "#{(File.size(aab_path) / 1_048_576.0).round(1)} MB"],
|
|
97
|
+
["Type", "AAB (main/GMS)"],
|
|
98
|
+
["services_type", "Unknown"],
|
|
99
|
+
["is_main", "true (implicit for AAB)"]
|
|
100
|
+
])
|
|
101
|
+
client.upload_aab(
|
|
102
|
+
package_name: package_name,
|
|
103
|
+
version_id: version_id,
|
|
104
|
+
file_path: aab_path
|
|
105
|
+
)
|
|
106
|
+
logger.success("AAB uploaded successfully")
|
|
107
|
+
|
|
108
|
+
elsif apk_path
|
|
109
|
+
validate_file!(apk_path, "apk_path", ".apk", logger)
|
|
110
|
+
logger.table([
|
|
111
|
+
["File", File.basename(apk_path)],
|
|
112
|
+
["Size", "#{(File.size(apk_path) / 1_048_576.0).round(1)} MB"],
|
|
113
|
+
["Type", "APK (main/GMS)"],
|
|
114
|
+
["services_type", "Unknown"],
|
|
115
|
+
["is_main", "true"]
|
|
116
|
+
])
|
|
117
|
+
client.upload_apk(
|
|
118
|
+
package_name: package_name,
|
|
119
|
+
version_id: version_id,
|
|
120
|
+
file_path: apk_path,
|
|
121
|
+
services_type: RustorePublish::RustoreClient::SERVICES_TYPE_UNKNOWN,
|
|
122
|
+
is_main_apk: true
|
|
123
|
+
)
|
|
124
|
+
logger.success("APK uploaded successfully")
|
|
125
|
+
else
|
|
126
|
+
logger.error(
|
|
127
|
+
"No build file provided",
|
|
128
|
+
hint: "Specify at least `aab_path` or `apk_path`"
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ── Step 4: Upload HMS APK (optional) ─────────────────────────────────
|
|
133
|
+
logger.step(4, "Uploading APK (HMS/secondary)")
|
|
134
|
+
|
|
135
|
+
if hms_apk_path
|
|
136
|
+
validate_file!(hms_apk_path, "hms_apk_path", ".apk", logger)
|
|
137
|
+
logger.table([
|
|
138
|
+
["File", File.basename(hms_apk_path)],
|
|
139
|
+
["Size", "#{(File.size(hms_apk_path) / 1_048_576.0).round(1)} MB"],
|
|
140
|
+
["Type", "APK (secondary/HMS)"],
|
|
141
|
+
["services_type", "HMS"],
|
|
142
|
+
["is_main", "false"]
|
|
143
|
+
])
|
|
144
|
+
client.upload_apk(
|
|
145
|
+
package_name: package_name,
|
|
146
|
+
version_id: version_id,
|
|
147
|
+
file_path: hms_apk_path,
|
|
148
|
+
services_type: RustorePublish::RustoreClient::SERVICES_TYPE_HMS,
|
|
149
|
+
is_main_apk: false
|
|
150
|
+
)
|
|
151
|
+
logger.success("HMS APK uploaded successfully")
|
|
152
|
+
else
|
|
153
|
+
logger.info("Skipped (no hms_apk_path provided)")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# ── Step 5: Submit for moderation ─────────────────────────────────────
|
|
157
|
+
logger.step(5, "Submitting for moderation")
|
|
158
|
+
client.submit_for_review(package_name: package_name, version_id: version_id, update_priority: params[:update_priority])
|
|
159
|
+
|
|
160
|
+
case publish_type
|
|
161
|
+
when "INSTANTLY"
|
|
162
|
+
logger.success("Submitted — RuStore will publish automatically after moderation")
|
|
163
|
+
when "DELAYED"
|
|
164
|
+
logger.success("Submitted — scheduled for #{release_date}")
|
|
165
|
+
when "MANUAL"
|
|
166
|
+
logger.success("Submitted — publish manually from RuStore Console after moderation passes")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
logger.finalize
|
|
170
|
+
UI.success("#{RustorePublish::RustoreLogger::PREFIX} All done! #{package_name} versionId=#{version_id} submitted to RuStore.")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ── Parameter definitions ────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def self.available_options
|
|
176
|
+
[
|
|
177
|
+
# ── Authentication ─────────────────────────────────────────────────
|
|
178
|
+
FastlaneCore::ConfigItem.new(
|
|
179
|
+
key: :key_id,
|
|
180
|
+
env_name: "RUSTORE_KEY_ID",
|
|
181
|
+
description: "API key ID from RuStore Console → Company → API RuStore",
|
|
182
|
+
type: String,
|
|
183
|
+
optional: false
|
|
184
|
+
),
|
|
185
|
+
FastlaneCore::ConfigItem.new(
|
|
186
|
+
key: :private_key_path,
|
|
187
|
+
env_name: "RUSTORE_PRIVATE_KEY_PATH",
|
|
188
|
+
description: "Path to RSA private key PEM file (mutually exclusive with private_key)",
|
|
189
|
+
type: String,
|
|
190
|
+
optional: true,
|
|
191
|
+
verify_block: proc { |v| UI.user_error!("private_key_path file not found: #{v}") unless File.exist?(v) }
|
|
192
|
+
),
|
|
193
|
+
FastlaneCore::ConfigItem.new(
|
|
194
|
+
key: :private_key,
|
|
195
|
+
env_name: "RUSTORE_PRIVATE_KEY",
|
|
196
|
+
description: "RSA private key PEM content (mutually exclusive with private_key_path)",
|
|
197
|
+
type: String,
|
|
198
|
+
optional: true,
|
|
199
|
+
sensitive: true
|
|
200
|
+
),
|
|
201
|
+
|
|
202
|
+
# ── App identity ───────────────────────────────────────────────────
|
|
203
|
+
FastlaneCore::ConfigItem.new(
|
|
204
|
+
key: :package_name,
|
|
205
|
+
env_name: "RUSTORE_PACKAGE_NAME",
|
|
206
|
+
description: "Application package name (e.g. com.example.app)",
|
|
207
|
+
type: String,
|
|
208
|
+
optional: false
|
|
209
|
+
),
|
|
210
|
+
|
|
211
|
+
# ── Build files ────────────────────────────────────────────────────
|
|
212
|
+
FastlaneCore::ConfigItem.new(
|
|
213
|
+
key: :aab_path,
|
|
214
|
+
env_name: "RUSTORE_AAB_PATH",
|
|
215
|
+
description: "Path to AAB file (Google/GMS build — becomes the main artifact)",
|
|
216
|
+
type: String,
|
|
217
|
+
optional: true,
|
|
218
|
+
verify_block: proc { |v| UI.user_error!("aab_path file not found: #{v}") unless File.exist?(v) }
|
|
219
|
+
),
|
|
220
|
+
FastlaneCore::ConfigItem.new(
|
|
221
|
+
key: :apk_path,
|
|
222
|
+
env_name: "RUSTORE_APK_PATH",
|
|
223
|
+
description: "Path to APK file (used when aab_path is not provided; becomes main artifact)",
|
|
224
|
+
type: String,
|
|
225
|
+
optional: true,
|
|
226
|
+
verify_block: proc { |v| UI.user_error!("apk_path file not found: #{v}") unless File.exist?(v) }
|
|
227
|
+
),
|
|
228
|
+
FastlaneCore::ConfigItem.new(
|
|
229
|
+
key: :hms_apk_path,
|
|
230
|
+
env_name: "RUSTORE_HMS_APK_PATH",
|
|
231
|
+
description: "Path to Huawei/HMS APK file (uploaded with servicesType=HMS, isMainApk=false)",
|
|
232
|
+
type: String,
|
|
233
|
+
optional: true,
|
|
234
|
+
verify_block: proc { |v| UI.user_error!("hms_apk_path file not found: #{v}") unless File.exist?(v) }
|
|
235
|
+
),
|
|
236
|
+
|
|
237
|
+
# ── Release notes ──────────────────────────────────────────────────
|
|
238
|
+
FastlaneCore::ConfigItem.new(
|
|
239
|
+
key: :whats_new,
|
|
240
|
+
env_name: "RUSTORE_WHATS_NEW",
|
|
241
|
+
description: "Release notes shown to users (max 5000 chars, plain text)",
|
|
242
|
+
type: String,
|
|
243
|
+
optional: true,
|
|
244
|
+
verify_block: proc { |v| UI.user_error!("whats_new must not exceed 5000 characters") if v.length > 5000 }
|
|
245
|
+
),
|
|
246
|
+
FastlaneCore::ConfigItem.new(
|
|
247
|
+
key: :moder_info,
|
|
248
|
+
env_name: "RUSTORE_MODER_INFO",
|
|
249
|
+
description: "Notes for the RuStore moderator, e.g. test credentials (max 180 chars)",
|
|
250
|
+
type: String,
|
|
251
|
+
optional: true,
|
|
252
|
+
verify_block: proc { |v| UI.user_error!("moder_info must not exceed 180 characters") if v.length > 180 }
|
|
253
|
+
),
|
|
254
|
+
|
|
255
|
+
# ── Publication ────────────────────────────────────────────────────
|
|
256
|
+
FastlaneCore::ConfigItem.new(
|
|
257
|
+
key: :publish_type,
|
|
258
|
+
env_name: "RUSTORE_PUBLISH_TYPE",
|
|
259
|
+
description: "INSTANTLY — auto-publish after moderation; MANUAL — publish from Console; DELAYED — scheduled via release_date",
|
|
260
|
+
default_value: "INSTANTLY",
|
|
261
|
+
type: String,
|
|
262
|
+
optional: true,
|
|
263
|
+
verify_block: proc { |v| UI.user_error!("publish_type must be INSTANTLY, MANUAL, or DELAYED") unless %w[INSTANTLY MANUAL DELAYED].include?(v) }
|
|
264
|
+
),
|
|
265
|
+
FastlaneCore::ConfigItem.new(
|
|
266
|
+
key: :release_date,
|
|
267
|
+
env_name: "RUSTORE_RELEASE_DATE",
|
|
268
|
+
description: "Scheduled release datetime in ISO 8601 format (only used with DELAYED)",
|
|
269
|
+
type: String,
|
|
270
|
+
optional: true
|
|
271
|
+
),
|
|
272
|
+
FastlaneCore::ConfigItem.new(
|
|
273
|
+
key: :rollout_percentage,
|
|
274
|
+
env_name: "RUSTORE_ROLLOUT_PERCENTAGE",
|
|
275
|
+
description: "Staged rollout: allowed values are 5, 10, 25, 50, 75, 100; omit for full rollout",
|
|
276
|
+
type: Integer,
|
|
277
|
+
optional: true,
|
|
278
|
+
verify_block: proc { |v|
|
|
279
|
+
UI.user_error!("rollout_percentage must be one of: 5, 10, 25, 50, 75, 100") unless [5, 10, 25, 50, 75, 100].include?(v)
|
|
280
|
+
}
|
|
281
|
+
),
|
|
282
|
+
FastlaneCore::ConfigItem.new(
|
|
283
|
+
key: :update_priority,
|
|
284
|
+
env_name: "RUSTORE_UPDATE_PRIORITY",
|
|
285
|
+
description: "In-app update priority sent with the moderation request: 0 (min) to 5 (max)",
|
|
286
|
+
default_value: 0,
|
|
287
|
+
type: Integer,
|
|
288
|
+
optional: true,
|
|
289
|
+
verify_block: proc { |v| UI.user_error!("update_priority must be between 0 and 5") unless (0..5).cover?(v) }
|
|
290
|
+
),
|
|
291
|
+
|
|
292
|
+
# ── Network ────────────────────────────────────────────────────────
|
|
293
|
+
FastlaneCore::ConfigItem.new(
|
|
294
|
+
key: :upload_timeout,
|
|
295
|
+
env_name: "RUSTORE_UPLOAD_TIMEOUT",
|
|
296
|
+
description: "Seconds to wait for RuStore to answer an AAB/APK upload; RuStore checks the file before answering",
|
|
297
|
+
default_value: RustorePublish::RustoreClient::DEFAULT_UPLOAD_TIMEOUT,
|
|
298
|
+
type: Integer,
|
|
299
|
+
optional: true,
|
|
300
|
+
verify_block: proc { |v| UI.user_error!("upload_timeout must be a positive number of seconds") unless v > 0 }
|
|
301
|
+
),
|
|
302
|
+
|
|
303
|
+
# ── Draft metadata (RuStore API v2) ──────────────────────────────
|
|
304
|
+
FastlaneCore::ConfigItem.new(
|
|
305
|
+
key: :developer_contacts,
|
|
306
|
+
description: "Developer contact info Hash: { email:, website:, vk_community: } — at least one key required",
|
|
307
|
+
type: Hash,
|
|
308
|
+
optional: true,
|
|
309
|
+
verify_block: proc { |v|
|
|
310
|
+
UI.user_error!("developer_contacts must contain at least one of: email, website, vk_community") if v.empty?
|
|
311
|
+
allowed = %i[email website vk_community]
|
|
312
|
+
v.each_key do |k|
|
|
313
|
+
unless allowed.include?(k.to_sym)
|
|
314
|
+
UI.user_error!("developer_contacts: unknown sub-key '#{k}' (accepted: #{allowed.join(', ')})")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
}
|
|
318
|
+
),
|
|
319
|
+
FastlaneCore::ConfigItem.new(
|
|
320
|
+
key: :app_name,
|
|
321
|
+
env_name: "RUSTORE_APP_NAME",
|
|
322
|
+
description: "App display name (max 50 chars)",
|
|
323
|
+
type: String,
|
|
324
|
+
optional: true,
|
|
325
|
+
verify_block: proc { |v|
|
|
326
|
+
UI.user_error!("app_name must not exceed 50 characters") if v.length > 50
|
|
327
|
+
}
|
|
328
|
+
),
|
|
329
|
+
FastlaneCore::ConfigItem.new(
|
|
330
|
+
key: :app_type,
|
|
331
|
+
env_name: "RUSTORE_APP_TYPE",
|
|
332
|
+
description: "App type: GAMES or MAIN",
|
|
333
|
+
type: String,
|
|
334
|
+
optional: true,
|
|
335
|
+
verify_block: proc { |v|
|
|
336
|
+
UI.user_error!("app_type must be 'GAMES' or 'MAIN'") unless %w[GAMES MAIN].include?(v)
|
|
337
|
+
}
|
|
338
|
+
),
|
|
339
|
+
FastlaneCore::ConfigItem.new(
|
|
340
|
+
key: :age_legal,
|
|
341
|
+
env_name: "RUSTORE_AGE_LEGAL",
|
|
342
|
+
description: "Age rating: 0+, 6+, 12+, 16+, or 18+",
|
|
343
|
+
type: String,
|
|
344
|
+
optional: true,
|
|
345
|
+
verify_block: proc { |v|
|
|
346
|
+
allowed = %w[0+ 6+ 12+ 16+ 18+]
|
|
347
|
+
UI.user_error!("age_legal must be one of: #{allowed.join(', ')}") unless allowed.include?(v)
|
|
348
|
+
}
|
|
349
|
+
),
|
|
350
|
+
FastlaneCore::ConfigItem.new(
|
|
351
|
+
key: :short_description,
|
|
352
|
+
env_name: "RUSTORE_SHORT_DESCRIPTION",
|
|
353
|
+
description: "Brief description shown in app list (max 80 chars)",
|
|
354
|
+
type: String,
|
|
355
|
+
optional: true,
|
|
356
|
+
verify_block: proc { |v|
|
|
357
|
+
UI.user_error!("short_description must not exceed 80 characters") if v.length > 80
|
|
358
|
+
}
|
|
359
|
+
),
|
|
360
|
+
FastlaneCore::ConfigItem.new(
|
|
361
|
+
key: :full_description,
|
|
362
|
+
env_name: "RUSTORE_FULL_DESCRIPTION",
|
|
363
|
+
description: "Full description on the app page (max 4000 chars)",
|
|
364
|
+
type: String,
|
|
365
|
+
optional: true,
|
|
366
|
+
verify_block: proc { |v|
|
|
367
|
+
UI.user_error!("full_description must not exceed 4000 characters") if v.length > 4000
|
|
368
|
+
}
|
|
369
|
+
),
|
|
370
|
+
FastlaneCore::ConfigItem.new(
|
|
371
|
+
key: :price_value,
|
|
372
|
+
env_name: "RUSTORE_PRICE_VALUE",
|
|
373
|
+
description: "Price in kopecks as a string of digits, e.g. '8799' for 87.99 RUB",
|
|
374
|
+
type: String,
|
|
375
|
+
optional: true,
|
|
376
|
+
verify_block: proc { |v|
|
|
377
|
+
UI.user_error!("price_value must be a non-empty string of digits") unless v.is_a?(String) && v =~ /\A\d+\z/
|
|
378
|
+
}
|
|
379
|
+
),
|
|
380
|
+
FastlaneCore::ConfigItem.new(
|
|
381
|
+
key: :categories,
|
|
382
|
+
description: "App categories, e.g. ['news', 'education'] — max 2 elements",
|
|
383
|
+
type: Array,
|
|
384
|
+
optional: true,
|
|
385
|
+
verify_block: proc { |v|
|
|
386
|
+
UI.user_error!("categories must be an Array") unless v.is_a?(Array)
|
|
387
|
+
UI.user_error!("categories must not exceed 2 elements") if v.size > 2
|
|
388
|
+
v.each do |c|
|
|
389
|
+
unless c.is_a?(String) && !c.strip.empty?
|
|
390
|
+
UI.user_error!("categories elements must be non-empty strings")
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
}
|
|
394
|
+
),
|
|
395
|
+
FastlaneCore::ConfigItem.new(
|
|
396
|
+
key: :seo_tag_ids,
|
|
397
|
+
description: "RuStore search tag IDs (max 5 integers)",
|
|
398
|
+
type: Array,
|
|
399
|
+
optional: true,
|
|
400
|
+
verify_block: proc { |v|
|
|
401
|
+
UI.user_error!("seo_tag_ids must be an Array") unless v.is_a?(Array)
|
|
402
|
+
UI.user_error!("seo_tag_ids must not exceed 5 elements") if v.size > 5
|
|
403
|
+
v.each do |t|
|
|
404
|
+
UI.user_error!("seo_tag_ids elements must be integers") unless t.is_a?(Integer)
|
|
405
|
+
end
|
|
406
|
+
}
|
|
407
|
+
),
|
|
408
|
+
FastlaneCore::ConfigItem.new(
|
|
409
|
+
key: :faq,
|
|
410
|
+
description: "FAQ items array (max 10), each Hash with question (max 120 chars) and answer (max 500 chars)",
|
|
411
|
+
type: Array,
|
|
412
|
+
optional: true,
|
|
413
|
+
verify_block: proc { |v|
|
|
414
|
+
UI.user_error!("faq must be an Array") unless v.is_a?(Array)
|
|
415
|
+
UI.user_error!("faq must not exceed 10 elements") if v.size > 10
|
|
416
|
+
v.each_with_index do |item, idx|
|
|
417
|
+
UI.user_error!("faq[#{idx}] must be a Hash") unless item.is_a?(Hash)
|
|
418
|
+
q = item[:question] || item["question"]
|
|
419
|
+
a = item[:answer] || item["answer"]
|
|
420
|
+
UI.user_error!("faq[#{idx}] missing :question") if q.nil?
|
|
421
|
+
UI.user_error!("faq[#{idx}] missing :answer") if a.nil?
|
|
422
|
+
UI.user_error!("faq[#{idx}].question must not exceed 120 characters") if q.length > 120
|
|
423
|
+
UI.user_error!("faq[#{idx}].answer must not exceed 500 characters") if a.length > 500
|
|
424
|
+
end
|
|
425
|
+
}
|
|
426
|
+
)
|
|
427
|
+
]
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def self.description
|
|
431
|
+
"Upload and publish Android apps (APK/AAB) to RuStore app store"
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def self.details
|
|
435
|
+
<<~DETAILS
|
|
436
|
+
Uploads an Android application to the RuStore app store via the RuStore Public API.
|
|
437
|
+
|
|
438
|
+
Supports multi-file versions in a single API version:
|
|
439
|
+
- AAB (Google/GMS build) as the main artifact
|
|
440
|
+
- APK (Huawei/HMS build) as a secondary artifact (servicesType=HMS)
|
|
441
|
+
|
|
442
|
+
The pipeline always finishes immediately after submitting for moderation.
|
|
443
|
+
Publication behaviour is controlled by publish_type:
|
|
444
|
+
INSTANTLY — RuStore auto-publishes after moderation passes (default)
|
|
445
|
+
DELAYED — scheduled publication via release_date
|
|
446
|
+
MANUAL — moderation runs, then publish manually from RuStore Console
|
|
447
|
+
|
|
448
|
+
GitLab CI: when GITLAB_CI=true, each step is wrapped in a collapsible
|
|
449
|
+
section visible in the Pipeline web UI.
|
|
450
|
+
|
|
451
|
+
Requires at least one active version already published in RuStore Console.
|
|
452
|
+
DETAILS
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def self.authors
|
|
456
|
+
["Sergey Gorban"]
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def self.example_usage
|
|
460
|
+
[
|
|
461
|
+
'# Minimal — AAB only, INSTANTLY (default)',
|
|
462
|
+
'rustore_upload(',
|
|
463
|
+
' package_name: "com.example.app",',
|
|
464
|
+
' key_id: ENV["RUSTORE_KEY_ID"],',
|
|
465
|
+
' private_key_path: "rustore_private_key.pem",',
|
|
466
|
+
' aab_path: "app/build/outputs/bundle/release/app-release.aab"',
|
|
467
|
+
')',
|
|
468
|
+
'',
|
|
469
|
+
'# AAB (GMS) + APK (HMS), staged rollout',
|
|
470
|
+
'rustore_upload(',
|
|
471
|
+
' package_name: "com.example.app",',
|
|
472
|
+
' key_id: ENV["RUSTORE_KEY_ID"],',
|
|
473
|
+
' private_key: ENV["RUSTORE_PRIVATE_KEY"],',
|
|
474
|
+
' aab_path: "app/build/outputs/bundle/gmsRelease/app-gms-release.aab",',
|
|
475
|
+
' hms_apk_path: "app/build/outputs/apk/hmsRelease/app-hms-release.apk",',
|
|
476
|
+
' publish_type: "INSTANTLY",',
|
|
477
|
+
' rollout_percentage: 20',
|
|
478
|
+
')',
|
|
479
|
+
'',
|
|
480
|
+
'# Scheduled release',
|
|
481
|
+
'rustore_upload(',
|
|
482
|
+
' package_name: "com.example.app",',
|
|
483
|
+
' key_id: ENV["RUSTORE_KEY_ID"],',
|
|
484
|
+
' private_key: ENV["RUSTORE_PRIVATE_KEY"],',
|
|
485
|
+
' aab_path: "...",',
|
|
486
|
+
' publish_type: "DELAYED",',
|
|
487
|
+
' release_date: "2025-03-01T10:00:00+03:00"',
|
|
488
|
+
')'
|
|
489
|
+
].join("\n")
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def self.is_supported?(platform)
|
|
493
|
+
platform == :android
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
private
|
|
497
|
+
|
|
498
|
+
def self.validate_file!(path, param_name, expected_ext, logger)
|
|
499
|
+
unless File.exist?(path)
|
|
500
|
+
logger.error(
|
|
501
|
+
"File not found for #{param_name}: #{path}",
|
|
502
|
+
hint: "Check that the build step ran before rustore_upload and the path is correct"
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
ext = File.extname(path).downcase
|
|
506
|
+
unless ext == expected_ext
|
|
507
|
+
logger.error(
|
|
508
|
+
"#{param_name} has unexpected extension '#{ext}' (expected '#{expected_ext}'): #{path}",
|
|
509
|
+
hint: "Make sure you passed the correct file path"
|
|
510
|
+
)
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def self.normalize_developer_contacts(contacts)
|
|
515
|
+
map = { email: :email, website: :website, vk_community: :vkCommunity }
|
|
516
|
+
contacts.each_with_object({}) do |(k, v), out|
|
|
517
|
+
mapped = map[k.to_sym]
|
|
518
|
+
out[mapped] = v if mapped
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
|
|
3
|
+
PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
|
|
4
|
+
ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
|
|
5
|
+
Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
|
|
6
|
+
VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
|
|
7
|
+
YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
|
|
8
|
+
dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
|
|
9
|
+
qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
|
|
10
|
+
XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
|
|
11
|
+
zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
|
|
12
|
+
YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
|
|
13
|
+
Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
|
|
14
|
+
U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
|
|
15
|
+
4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
|
|
16
|
+
G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
|
|
17
|
+
BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
|
|
18
|
+
ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
|
|
19
|
+
OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
|
|
20
|
+
BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
|
|
21
|
+
BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
|
|
22
|
+
AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
|
|
23
|
+
tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
|
|
24
|
+
W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
|
|
25
|
+
/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
|
|
26
|
+
AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
|
|
27
|
+
C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
|
|
28
|
+
4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
|
|
29
|
+
WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
|
|
30
|
+
D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
|
|
31
|
+
EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
|
|
32
|
+
391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
|
|
33
|
+
-----END CERTIFICATE-----
|