mysigner 0.1.0
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/.DS_Store +0 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +137 -0
- data/LICENSE +201 -0
- data/MANUAL_TEST.md +341 -0
- data/README.md +493 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/mysigner +5 -0
- data/lib/mysigner/build/android_executor.rb +367 -0
- data/lib/mysigner/build/android_parser.rb +293 -0
- data/lib/mysigner/build/configurator.rb +126 -0
- data/lib/mysigner/build/detector.rb +388 -0
- data/lib/mysigner/build/error_analyzer.rb +193 -0
- data/lib/mysigner/build/executor.rb +176 -0
- data/lib/mysigner/build/parser.rb +206 -0
- data/lib/mysigner/cli/auth_commands.rb +1381 -0
- data/lib/mysigner/cli/build_commands.rb +2095 -0
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
- data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
- data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
- data/lib/mysigner/cli/concerns/helpers.rb +63 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
- data/lib/mysigner/cli/resource_commands.rb +2670 -0
- data/lib/mysigner/cli.rb +43 -0
- data/lib/mysigner/client.rb +189 -0
- data/lib/mysigner/config.rb +311 -0
- data/lib/mysigner/export/exporter.rb +150 -0
- data/lib/mysigner/signing/certificate_checker.rb +148 -0
- data/lib/mysigner/signing/keystore_manager.rb +239 -0
- data/lib/mysigner/signing/validator.rb +150 -0
- data/lib/mysigner/signing/wizard.rb +784 -0
- data/lib/mysigner/upload/app_store_automation.rb +402 -0
- data/lib/mysigner/upload/app_store_submission.rb +312 -0
- data/lib/mysigner/upload/play_store_uploader.rb +378 -0
- data/lib/mysigner/upload/uploader.rb +373 -0
- data/lib/mysigner/version.rb +3 -0
- data/lib/mysigner.rb +15 -0
- data/mysigner.gemspec +78 -0
- data/test_manual.rb +102 -0
- metadata +286 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
module Mysigner
|
|
2
|
+
module Upload
|
|
3
|
+
class AppStoreAutomation
|
|
4
|
+
class AutomationError < Mysigner::Error; end
|
|
5
|
+
|
|
6
|
+
DEFAULT_WAIT_TIMEOUT = 900 # 15 minutes
|
|
7
|
+
DEFAULT_POLL_INTERVAL = 15
|
|
8
|
+
|
|
9
|
+
attr_reader :wait_enabled, :poll_interval, :timeout, :no_submit
|
|
10
|
+
|
|
11
|
+
def initialize(client:, organization_id:, opts: {})
|
|
12
|
+
@client = client
|
|
13
|
+
@organization_id = organization_id
|
|
14
|
+
@wait_enabled = opts.key?(:wait) ? !!opts[:wait] : true
|
|
15
|
+
|
|
16
|
+
poll = opts[:poll_interval] || opts[:poll_seconds]
|
|
17
|
+
poll = poll.to_i if poll
|
|
18
|
+
@poll_interval = poll && poll.positive? ? poll : DEFAULT_POLL_INTERVAL
|
|
19
|
+
|
|
20
|
+
timeout = opts[:timeout] || opts[:timeout_seconds]
|
|
21
|
+
timeout = timeout.to_i if timeout
|
|
22
|
+
@timeout = timeout && timeout.positive? ? timeout : DEFAULT_WAIT_TIMEOUT
|
|
23
|
+
|
|
24
|
+
@no_submit = !!opts[:no_submit]
|
|
25
|
+
@now = opts[:now]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def perform!(metadata:, build_info:, metadata_overrides: {})
|
|
29
|
+
build_info = symbolize_keys(build_info)
|
|
30
|
+
metadata = metadata || {}
|
|
31
|
+
|
|
32
|
+
result = {
|
|
33
|
+
wait: {
|
|
34
|
+
enabled: @wait_enabled,
|
|
35
|
+
poll_seconds: @poll_interval,
|
|
36
|
+
timeout_seconds: @timeout,
|
|
37
|
+
timed_out: false,
|
|
38
|
+
elapsed_seconds: 0,
|
|
39
|
+
last_state: nil
|
|
40
|
+
},
|
|
41
|
+
submitted: false,
|
|
42
|
+
skip_reason: nil,
|
|
43
|
+
submission_source: nil
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
puts ""
|
|
47
|
+
puts "🤖 App Store automation in progress..."
|
|
48
|
+
puts ""
|
|
49
|
+
|
|
50
|
+
app = ensure_app(build_info[:bundle_id])
|
|
51
|
+
raise AutomationError, "App with bundle ID #{build_info[:bundle_id]} not found" unless app
|
|
52
|
+
|
|
53
|
+
build, wait_status = wait_for_build(app['id'], build_info)
|
|
54
|
+
result[:wait].merge!(wait_status)
|
|
55
|
+
|
|
56
|
+
unless build
|
|
57
|
+
version_info = [build_info[:version], build_info[:build_number]].compact.join(' / ')
|
|
58
|
+
version_info = "for #{version_info}" unless version_info.empty?
|
|
59
|
+
raise AutomationError, "No processed build found #{version_info}. Upload a build first with 'mysigner ship appstore --wait'"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
unless build_processed?(build)
|
|
63
|
+
raise AutomationError, "Build #{build_info[:version]} (#{build_info[:build_number]}) is still processing. Wait for it or use --wait flag."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
version = ensure_app_store_version(app_id: app['id'], metadata: metadata, overrides: metadata_overrides)
|
|
67
|
+
attach_build_to_version(version_id: version['id'], build_id: build['id'])
|
|
68
|
+
|
|
69
|
+
should_submit, submit_source, skip_reason = should_submit_with_reason(metadata, metadata_overrides)
|
|
70
|
+
|
|
71
|
+
if should_submit
|
|
72
|
+
submit_for_review(
|
|
73
|
+
version_id: version['id'],
|
|
74
|
+
version_string: version['versionString'],
|
|
75
|
+
metadata: metadata,
|
|
76
|
+
overrides: metadata_overrides
|
|
77
|
+
)
|
|
78
|
+
puts "✓ Submitted for App Store review"
|
|
79
|
+
result[:submitted] = true
|
|
80
|
+
result[:submission_source] = submit_source
|
|
81
|
+
else
|
|
82
|
+
puts "💡 Skipping automatic submission (#{skip_reason})"
|
|
83
|
+
result[:skip_reason] = skip_reason
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
puts ""
|
|
87
|
+
puts "✅ App Store automation complete"
|
|
88
|
+
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def ensure_app(bundle_id)
|
|
95
|
+
response = @client.get(
|
|
96
|
+
"/api/v1/organizations/#{@organization_id}/apple_apps",
|
|
97
|
+
params: { bundle_id: bundle_id }
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
Array(response[:data]['data']['apps']).first
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
raise AutomationError, "Failed to fetch app: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def wait_for_build(app_id, build_info)
|
|
106
|
+
build = latest_build(app_id, build_info)
|
|
107
|
+
status = {
|
|
108
|
+
timed_out: false,
|
|
109
|
+
elapsed_seconds: 0,
|
|
110
|
+
last_state: build_state(build)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return [build, status] unless @wait_enabled
|
|
114
|
+
|
|
115
|
+
puts "⏳ Waiting for Apple to finish processing the build..."
|
|
116
|
+
puts " Polling every #{@poll_interval}s (timeout #{@timeout}s)"
|
|
117
|
+
print ""
|
|
118
|
+
|
|
119
|
+
start_time = current_time
|
|
120
|
+
|
|
121
|
+
loop do
|
|
122
|
+
build = latest_build(app_id, build_info)
|
|
123
|
+
status[:last_state] = build_state(build)
|
|
124
|
+
|
|
125
|
+
if build && build_processed?(build)
|
|
126
|
+
puts "\r✓ Build is processed and ready".ljust(70)
|
|
127
|
+
puts ""
|
|
128
|
+
return [build, status]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
elapsed = current_time - start_time
|
|
132
|
+
status[:elapsed_seconds] = elapsed
|
|
133
|
+
|
|
134
|
+
if elapsed >= @timeout
|
|
135
|
+
status[:timed_out] = true
|
|
136
|
+
puts "\r✗ Timed out after #{format_duration(elapsed)} (use --asc-timeout-seconds to extend)".ljust(90)
|
|
137
|
+
puts ""
|
|
138
|
+
return [build, status]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
state_msg = status[:last_state] || (build ? 'pending from Apple' : 'waiting for sync')
|
|
142
|
+
print "\r Waiting #{format_duration(elapsed)} / #{format_duration(@timeout)} – #{state_msg}"
|
|
143
|
+
$stdout.flush
|
|
144
|
+
sleep @poll_interval
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def latest_build(app_id, build_info)
|
|
149
|
+
# For ship appstore (use_latest): get absolute latest build, no filtering
|
|
150
|
+
# For mysigner submit: filter by version/build_number to get a specific one
|
|
151
|
+
params = {
|
|
152
|
+
app_id: app_id,
|
|
153
|
+
processed_only: !@wait_enabled
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Only filter by version/build if NOT using latest
|
|
157
|
+
unless build_info[:use_latest]
|
|
158
|
+
params[:version] = build_info[:version] if build_info[:version]
|
|
159
|
+
params[:build_number] = build_info[:build_number] if build_info[:build_number]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Apply min_build_number filter if set in release config
|
|
163
|
+
if build_info[:min_build_number]
|
|
164
|
+
params[:min_build_number] = build_info[:min_build_number]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
response = @client.get(
|
|
168
|
+
"/api/v1/organizations/#{@organization_id}/builds",
|
|
169
|
+
params: params.compact
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
builds = Array(response[:data]['data']['builds'])
|
|
173
|
+
|
|
174
|
+
# Smart build selection: filter by min_build_number client-side if API doesn't support it
|
|
175
|
+
if build_info[:min_build_number] && builds.any?
|
|
176
|
+
min_bn = build_info[:min_build_number].to_i
|
|
177
|
+
builds = builds.select { |b| b['build_number'].to_i >= min_bn }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
builds.first # Already ordered by uploaded_date desc
|
|
181
|
+
rescue Mysigner::NotFoundError
|
|
182
|
+
nil
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
raise AutomationError, "Failed to fetch build: #{e.message}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_processed?(build)
|
|
188
|
+
processing_state = build['processing_state'] || build.dig('attributes', 'processingState')
|
|
189
|
+
status = build['status'] || build.dig('attributes', 'buildStatus')
|
|
190
|
+
|
|
191
|
+
%w[VALID PROCESSING_COMPLETE].include?(processing_state) || status == 'valid'
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def build_state(build)
|
|
195
|
+
return nil unless build
|
|
196
|
+
|
|
197
|
+
state = build['processing_state'] || build.dig('attributes', 'processingState')
|
|
198
|
+
status = build['status'] || build.dig('attributes', 'buildStatus')
|
|
199
|
+
joined = [state, status].compact.map { |value| value.to_s.upcase }.reject(&:empty?).join(' / ')
|
|
200
|
+
joined.empty? ? 'processing' : joined
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def ensure_app_store_version(app_id:, metadata:, overrides: {})
|
|
204
|
+
desired_version = overrides['version_string'] || metadata['version_string'] || metadata['version']
|
|
205
|
+
desired_version ||= metadata.dig('localizations', 0, 'versionString')
|
|
206
|
+
|
|
207
|
+
current_version = fetch_editable_version(app_id)
|
|
208
|
+
|
|
209
|
+
if current_version && version_matches?(current_version, desired_version)
|
|
210
|
+
puts "✓ Reusing existing App Store version #{current_version['versionString']}"
|
|
211
|
+
update_version(current_version['id'], metadata, overrides)
|
|
212
|
+
current_version
|
|
213
|
+
else
|
|
214
|
+
version_to_create = desired_version || build_default_version
|
|
215
|
+
puts "✨ Creating new App Store version #{version_to_create}"
|
|
216
|
+
create_version(app_id, version_to_create, metadata, overrides)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fetch_editable_version(app_id)
|
|
221
|
+
response = @client.get(
|
|
222
|
+
"/api/v1/organizations/#{@organization_id}/app_store_versions",
|
|
223
|
+
params: { app_id: app_id, editable: true }
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
Array(response[:data]['data']['versions']).first
|
|
227
|
+
rescue StandardError => e
|
|
228
|
+
raise AutomationError, "Failed to fetch App Store versions: #{e.message}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def version_matches?(version, desired)
|
|
232
|
+
return false unless desired
|
|
233
|
+
|
|
234
|
+
normalized = desired.to_s.strip
|
|
235
|
+
return false if normalized.empty?
|
|
236
|
+
|
|
237
|
+
version['versionString'] == normalized || version.dig('attributes', 'versionString') == normalized
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_default_version
|
|
241
|
+
current_time.strftime('%Y.%m.%d')
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def create_version(app_id, version_string, metadata, overrides)
|
|
245
|
+
payload = {
|
|
246
|
+
app_store_version: {
|
|
247
|
+
app_id: app_id,
|
|
248
|
+
version_string: version_string,
|
|
249
|
+
release_type: determine_release_type(metadata, overrides),
|
|
250
|
+
earliest_release_date: determine_earliest_release_date(metadata, overrides),
|
|
251
|
+
attributes: extract_version_attributes(metadata, overrides)
|
|
252
|
+
}.compact
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
response = @client.post(
|
|
256
|
+
"/api/v1/organizations/#{@organization_id}/app_store_versions",
|
|
257
|
+
body: payload
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
response[:data]['data']
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
raise AutomationError, "Failed to create App Store version: #{e.message}"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def determine_earliest_release_date(metadata, overrides)
|
|
266
|
+
date = overrides['earliest_release_date'] || metadata['earliest_release_date']
|
|
267
|
+
return nil unless date
|
|
268
|
+
|
|
269
|
+
# Convert to ISO 8601 if not already
|
|
270
|
+
date.respond_to?(:iso8601) ? date.iso8601 : date.to_s
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def update_version(version_id, metadata, overrides)
|
|
274
|
+
payload = {
|
|
275
|
+
app_store_version: {
|
|
276
|
+
attributes: extract_version_attributes(metadata, overrides)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
@client.patch(
|
|
281
|
+
"/api/v1/organizations/#{@organization_id}/app_store_versions/#{version_id}",
|
|
282
|
+
body: payload
|
|
283
|
+
)
|
|
284
|
+
rescue StandardError => e
|
|
285
|
+
raise AutomationError, "Failed to update App Store version: #{e.message}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def determine_release_type(metadata, overrides)
|
|
289
|
+
result = overrides['release_type'] || metadata['release_type']
|
|
290
|
+
|
|
291
|
+
# FIX v7: Changed default from MANUAL to AFTER_APPROVAL
|
|
292
|
+
# Log deprecation notice if no explicit release_type is set
|
|
293
|
+
if result.nil?
|
|
294
|
+
if ENV['MYSIGNER_DEBUG'] || @deprecation_warned.nil?
|
|
295
|
+
puts " Note: Using default release_type AFTER_APPROVAL (was MANUAL in older versions)"
|
|
296
|
+
@deprecation_warned = true
|
|
297
|
+
end
|
|
298
|
+
result = 'AFTER_APPROVAL'
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
result
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def extract_version_attributes(metadata, overrides)
|
|
305
|
+
localizations = overrides['localizations'] || metadata['localizations'] || []
|
|
306
|
+
{
|
|
307
|
+
whats_new: overrides['whats_new'] || metadata['whats_new'],
|
|
308
|
+
support_url: overrides['support_url'] || metadata['support_url'],
|
|
309
|
+
marketing_url: overrides['marketing_url'] || metadata['marketing_url'],
|
|
310
|
+
privacy_policy_url: overrides['privacy_policy_url'] || metadata['privacy_policy_url'],
|
|
311
|
+
phased_release: overrides.fetch('phased_release', metadata['phased_release']),
|
|
312
|
+
localizations: localizations
|
|
313
|
+
}.compact
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def attach_build_to_version(version_id:, build_id:)
|
|
317
|
+
@client.post(
|
|
318
|
+
"/api/v1/organizations/#{@organization_id}/app_store_versions/#{version_id}/build",
|
|
319
|
+
body: { build_id: build_id }
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
puts "✓ Attached build to App Store version"
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
raise AutomationError, "Failed to attach build to version: #{e.message}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def should_submit_with_reason(metadata, overrides)
|
|
328
|
+
return [false, nil, '--no-auto-submit flag'] if @no_submit
|
|
329
|
+
|
|
330
|
+
if overrides.key?('auto_submit')
|
|
331
|
+
return overrides['auto_submit'] ? [true, 'CLI override', nil] : [false, nil, 'CLI override disabled auto_submit']
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
if metadata.key?('auto_submit')
|
|
335
|
+
return metadata['auto_submit'] ? [true, 'Dashboard configuration', nil] : [false, nil, 'Dashboard auto_submit disabled']
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
[false, nil, 'No auto_submit configuration']
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def submit_for_review(version_id:, version_string: nil, metadata: {}, overrides: {})
|
|
342
|
+
# Merge metadata and overrides
|
|
343
|
+
merged = metadata.merge(overrides)
|
|
344
|
+
|
|
345
|
+
# Get version string to check if first version
|
|
346
|
+
version_string ||= merged['version_string'] || merged['version'] || '1.0'
|
|
347
|
+
is_first_version = version_string.split('.').first.to_i <= 1
|
|
348
|
+
|
|
349
|
+
# Validate required fields for Apple submission
|
|
350
|
+
# Note: What's New is NOT required for version 1.0 (first release)
|
|
351
|
+
# Support URL may already be set in App Store Connect, so we only warn if missing
|
|
352
|
+
missing_fields = []
|
|
353
|
+
missing_fields << "What's New text" if !is_first_version && merged['whats_new'].to_s.strip.empty?
|
|
354
|
+
|
|
355
|
+
# Don't block on missing support_url - it may already be in App Store Connect
|
|
356
|
+
# Just warn about it
|
|
357
|
+
if merged['support_url'].to_s.strip.empty?
|
|
358
|
+
puts "⚠️ Note: No Support URL provided via CLI - using value from App Store Connect if available"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
unless missing_fields.empty?
|
|
362
|
+
raise AutomationError, "Cannot submit to Apple Store: missing required fields: #{missing_fields.join(', ')}. Please configure these in your My Signer dashboard or provide via --whats-new flag."
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
payload = {
|
|
366
|
+
whats_new: merged['whats_new'],
|
|
367
|
+
keywords: merged['keywords'],
|
|
368
|
+
marketing_url: merged['marketing_url'],
|
|
369
|
+
promotional_text: merged['promotional_text'],
|
|
370
|
+
support_url: merged['support_url'],
|
|
371
|
+
locale: merged['locale']
|
|
372
|
+
}.compact # Remove nil values
|
|
373
|
+
|
|
374
|
+
@client.post(
|
|
375
|
+
"/api/v1/organizations/#{@organization_id}/app_store_versions/#{version_id}/submit",
|
|
376
|
+
body: payload
|
|
377
|
+
)
|
|
378
|
+
rescue AutomationError
|
|
379
|
+
raise
|
|
380
|
+
rescue StandardError => e
|
|
381
|
+
raise AutomationError, "Failed to submit for review: #{e.message}"
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def symbolize_keys(hash)
|
|
385
|
+
hash.each_with_object({}) do |(key, value), memo|
|
|
386
|
+
memo[key.to_sym] = value
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def format_duration(seconds)
|
|
392
|
+
minutes = (seconds / 60).floor
|
|
393
|
+
seconds = (seconds % 60).round
|
|
394
|
+
format('%<m>02d:%<s>02d', m: minutes, s: seconds)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def current_time
|
|
398
|
+
(@now && @now.call) || Time.now
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|