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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/.travis.yml +7 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +7 -0
  9. data/Gemfile.lock +137 -0
  10. data/LICENSE +201 -0
  11. data/MANUAL_TEST.md +341 -0
  12. data/README.md +493 -0
  13. data/Rakefile +6 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/exe/mysigner +5 -0
  17. data/lib/mysigner/build/android_executor.rb +367 -0
  18. data/lib/mysigner/build/android_parser.rb +293 -0
  19. data/lib/mysigner/build/configurator.rb +126 -0
  20. data/lib/mysigner/build/detector.rb +388 -0
  21. data/lib/mysigner/build/error_analyzer.rb +193 -0
  22. data/lib/mysigner/build/executor.rb +176 -0
  23. data/lib/mysigner/build/parser.rb +206 -0
  24. data/lib/mysigner/cli/auth_commands.rb +1381 -0
  25. data/lib/mysigner/cli/build_commands.rb +2095 -0
  26. data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
  27. data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
  28. data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
  29. data/lib/mysigner/cli/concerns/helpers.rb +63 -0
  30. data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
  31. data/lib/mysigner/cli/resource_commands.rb +2670 -0
  32. data/lib/mysigner/cli.rb +43 -0
  33. data/lib/mysigner/client.rb +189 -0
  34. data/lib/mysigner/config.rb +311 -0
  35. data/lib/mysigner/export/exporter.rb +150 -0
  36. data/lib/mysigner/signing/certificate_checker.rb +148 -0
  37. data/lib/mysigner/signing/keystore_manager.rb +239 -0
  38. data/lib/mysigner/signing/validator.rb +150 -0
  39. data/lib/mysigner/signing/wizard.rb +784 -0
  40. data/lib/mysigner/upload/app_store_automation.rb +402 -0
  41. data/lib/mysigner/upload/app_store_submission.rb +312 -0
  42. data/lib/mysigner/upload/play_store_uploader.rb +378 -0
  43. data/lib/mysigner/upload/uploader.rb +373 -0
  44. data/lib/mysigner/version.rb +3 -0
  45. data/lib/mysigner.rb +15 -0
  46. data/mysigner.gemspec +78 -0
  47. data/test_manual.rb +102 -0
  48. 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