mysigner 0.1.7 → 0.2.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.
@@ -0,0 +1,432 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module Mysigner
7
+ module Upload
8
+ # Drives Apple's REST API directly to submit a freshly-uploaded build
9
+ # for App Store review in `--local-only` mode. Replaces the historical
10
+ # "submit-for-review is not automated" hand-off banner.
11
+ #
12
+ # The vault-mode submit path (Mysigner::Upload::AppStoreSubmission +
13
+ # AppStoreAutomation) calls MySigner-internal endpoints; in local-only
14
+ # we cannot — the whole point of local-only is no server round-trip. So
15
+ # we re-implement just the App Store Connect REST calls a submission
16
+ # actually needs, using the same JWT the AscRestUploader already minted.
17
+ #
18
+ # Flow (per https://developer.apple.com/documentation/appstoreconnectapi):
19
+ # 1. POLL /v1/builds?filter[app]=…&filter[version]=… until processingState == VALID
20
+ # 2. FIND /v1/apps/<id>/appStoreVersions?filter[versionString]=…
21
+ # or CREATE /v1/appStoreVersions when none exists in PREPARE_FOR_SUBMISSION
22
+ # 3. PATCH /v1/appStoreVersions/<v_id>/relationships/build (attach build)
23
+ # 4a. POST /v1/reviewSubmissions (create submission container)
24
+ # 4b. POST /v1/reviewSubmissionItems (attach the version)
25
+ # 4c. PATCH /v1/reviewSubmissions/<id> {submitted: true} (flip to submitted)
26
+ #
27
+ # WHY 4a/4b/4c instead of the older POST /v1/appStoreVersionSubmissions:
28
+ # Apple deprecated `appStoreVersionSubmissions` in favour of the
29
+ # `reviewSubmissions` choreography. The old endpoint silently 4xx's for
30
+ # apps onboarded after the cut-over, so we use the modern path
31
+ # unconditionally — it works for all apps regardless of vintage.
32
+ #
33
+ # Returns the submission id (String). Raises a typed error on each
34
+ # foreseeable failure so the CLI rescue can give a one-line, actionable
35
+ # hint without parsing Apple's raw error bodies.
36
+ class AscSubmitter
37
+ APPLE_ASC_BASE = 'https://api.appstoreconnect.apple.com'
38
+
39
+ # 30 minutes is the working ceiling for build processing on Apple's
40
+ # side. Most builds finish in <10 minutes but occasional Apple-side
41
+ # backlogs push past 15. Beyond 30 the user almost certainly wants to
42
+ # walk away rather than keep the CLI blocked.
43
+ DEFAULT_PROCESSING_TIMEOUT = 30 * 60
44
+ DEFAULT_PROCESSING_POLL_INTERVAL = 30
45
+
46
+ # Raised when /v1/builds never reports processingState == VALID
47
+ # within @processing_timeout. The build is still on Apple's side; the
48
+ # user can re-run `mysigner submit` later once processing completes.
49
+ class BuildProcessingTimeoutError < StandardError; end
50
+
51
+ # Raised when the only existing appStoreVersion for this marketing
52
+ # version is already READY_FOR_SALE (released). We refuse to silently
53
+ # auto-create a new version on the user's behalf — bumping the
54
+ # marketing version is a scope decision they own.
55
+ class VersionAlreadyReleasedError < StandardError; end
56
+
57
+ # Raised when an appStoreVersion already exists for this marketing
58
+ # version but is in an in-flight state where we can neither edit it
59
+ # nor create a sibling (Apple rejects POST /v1/appStoreVersions with
60
+ # RELATIONSHIP.INVALID in that case). The message names the actual
61
+ # Apple state and the next user action (wait, cancel, or bump
62
+ # MARKETING_VERSION) so the CLI rescue can stay one-line.
63
+ class VersionInFlightError < StandardError; end
64
+
65
+ # Raised when Apple rejects any step of the submit-for-review
66
+ # choreography (reviewSubmissions / reviewSubmissionItems / PATCH
67
+ # submitted=true). The message carries Apple's verbatim error body so
68
+ # the user can act — usually the cause is missing metadata
69
+ # (description, screenshots, what's new).
70
+ class SubmissionRejectedError < StandardError; end
71
+
72
+ # Raised on any other unexpected non-2xx response from Apple. Carries
73
+ # the HTTP status + body so failures surface loud (Rule 12). `status`
74
+ # and `retry_after` are exposed so the poll loop can branch on 429s
75
+ # without re-parsing the message string.
76
+ class AppleApiError < StandardError
77
+ attr_reader :status, :retry_after
78
+
79
+ def initialize(message, status: nil, retry_after: nil)
80
+ super(message)
81
+ @status = status
82
+ @retry_after = retry_after
83
+ end
84
+ end
85
+
86
+ def initialize(jwt:, apple_app_id:, cf_bundle_version:, cf_bundle_short_version_string:,
87
+ platform: 'IOS',
88
+ processing_timeout: DEFAULT_PROCESSING_TIMEOUT,
89
+ processing_poll_interval: DEFAULT_PROCESSING_POLL_INTERVAL,
90
+ logger: $stderr)
91
+ @jwt = jwt
92
+ @apple_app_id = apple_app_id.to_s
93
+ @cf_bundle_version = cf_bundle_version.to_s
94
+ @cf_bundle_short_version_string = cf_bundle_short_version_string.to_s
95
+ @platform = platform.to_s
96
+ @processing_timeout = processing_timeout
97
+ @processing_poll_interval = processing_poll_interval
98
+ @logger = logger
99
+ end
100
+
101
+ # Drives steps 1–4 end-to-end and returns the submission id on success.
102
+ def submit!
103
+ build_id = wait_for_build_valid!
104
+ version_id = find_or_create_app_store_version!
105
+ attach_build!(version_id, build_id)
106
+ submit_for_review_via_review_submissions!(version_id)
107
+ end
108
+
109
+ private
110
+
111
+ # Step 1 — poll /v1/builds until the matching build is processed.
112
+ # Apple's /v1/builds returns ALL builds for the app paginated; the
113
+ # filter[version]= keyword filters server-side on CFBundleVersion (the
114
+ # `version` attribute, despite the name). filter[app]= scopes to one app.
115
+ # processingState transitions: PROCESSING → VALID (good) | INVALID (bad).
116
+ #
117
+ # Resilience: transient Faraday errors (connection failed, timeout) and
118
+ # AppleApiError (e.g. 429 Too Many Requests, 5xx) are swallowed PER
119
+ # ITERATION — the only exit conditions are VALID (success), INVALID
120
+ # (typed error), or the wall-clock deadline (BuildProcessingTimeoutError).
121
+ # WHY: the 30-minute poll spans real network flakiness and Apple's
122
+ # rate limiter; one blip should not abort an otherwise healthy wait.
123
+ def wait_for_build_valid!
124
+ deadline = monotonic_now + @processing_timeout
125
+ last_state = nil
126
+
127
+ loop do
128
+ state, build_id, sleep_interval = poll_once(last_state)
129
+ return build_id if state == 'VALID'
130
+
131
+ if state == 'INVALID'
132
+ raise AppleApiError,
133
+ "Apple marked build #{@cf_bundle_version} as INVALID during processing. " \
134
+ 'Check App Store Connect for the diagnostic message.'
135
+ end
136
+
137
+ last_state = state if state
138
+
139
+ if monotonic_now >= deadline
140
+ raise BuildProcessingTimeoutError,
141
+ "Apple did not finish processing build #{@cf_bundle_version} within " \
142
+ "#{@processing_timeout / 60} minutes. " \
143
+ 'Re-run `mysigner submit` once it shows as Ready to Submit in App Store Connect.'
144
+ end
145
+
146
+ sleep sleep_interval
147
+ end
148
+ end
149
+
150
+ # One poll iteration. Returns [state, build_id, sleep_interval].
151
+ # `state` may be nil when (a) the GET raised transiently or (b) Apple
152
+ # returned an empty data array — both are "keep waiting" from the loop's
153
+ # POV. Transient errors are logged here so the caller stays linear.
154
+ def poll_once(last_state)
155
+ builds = apple_get_json('/v1/builds',
156
+ params: { 'filter[app]' => @apple_app_id,
157
+ 'filter[version]' => @cf_bundle_version })
158
+ match = Array(builds['data']).first
159
+ if match
160
+ state = match.dig('attributes', 'processingState')
161
+ if state != last_state && state != 'VALID'
162
+ log("[mysigner] App Store Connect: build #{@cf_bundle_short_version_string} (#{@cf_bundle_version}) processingState=#{state}")
163
+ end
164
+ [state, match['id'], @processing_poll_interval]
165
+ else
166
+ log("[mysigner] App Store Connect: waiting for build #{@cf_bundle_version} to appear (Apple may still be ingesting)...")
167
+ [nil, nil, @processing_poll_interval]
168
+ end
169
+ rescue AppleApiError => e
170
+ interval = retry_after_for(e)
171
+ log "[mysigner] poll attempt failed: #{e.class.name}: #{e.message} — retrying in #{interval}s"
172
+ [nil, nil, interval]
173
+ rescue Faraday::Error => e
174
+ log "[mysigner] poll attempt failed: #{e.class.name}: #{e.message} — retrying in #{@processing_poll_interval}s"
175
+ [nil, nil, @processing_poll_interval]
176
+ end
177
+
178
+ # Returns the sleep interval to use after a transient error. Honours
179
+ # Apple's `Retry-After` header (seconds) on 429 responses; otherwise
180
+ # falls back to the configured poll interval.
181
+ def retry_after_for(error)
182
+ if error.status == 429 && error.retry_after
183
+ [error.retry_after.to_i, @processing_poll_interval].max
184
+ else
185
+ @processing_poll_interval
186
+ end
187
+ end
188
+
189
+ # Step 2 — find an existing appStoreVersion for this marketing version
190
+ # that's still mutable (PREPARE_FOR_SUBMISSION), or create one.
191
+ #
192
+ # When a version exists in an in-flight state, posting to
193
+ # /v1/appStoreVersions returns Apple's RELATIONSHIP.INVALID error
194
+ # ("a duplicate appStoreVersion already exists") — useless for the
195
+ # CLI user. We pre-empt that by raising a typed error per state with
196
+ # an actionable next step, so the rescue chain can stay one-line.
197
+ def find_or_create_app_store_version!
198
+ existing = apple_get_json("/v1/apps/#{@apple_app_id}/appStoreVersions",
199
+ params: { 'filter[versionString]' => @cf_bundle_short_version_string })
200
+ versions = Array(existing['data'])
201
+ prepare = versions.find { |v| v.dig('attributes', 'appStoreState') == 'PREPARE_FOR_SUBMISSION' }
202
+ return prepare['id'] if prepare
203
+
204
+ if versions.any? { |v| v.dig('attributes', 'appStoreState') == 'READY_FOR_SALE' }
205
+ raise VersionAlreadyReleasedError,
206
+ "App Store version #{@cf_bundle_short_version_string} is already released (READY_FOR_SALE). " \
207
+ 'Bump MARKETING_VERSION in Xcode (e.g. 1.0 → 1.0.1), re-archive, and re-run.'
208
+ end
209
+
210
+ in_flight = versions.find { |v| IN_FLIGHT_STATES.include?(v.dig('attributes', 'appStoreState')) }
211
+ if in_flight
212
+ state = in_flight.dig('attributes', 'appStoreState')
213
+ raise VersionInFlightError,
214
+ "App Store version #{@cf_bundle_short_version_string} is in state #{state} — " \
215
+ "cannot create or edit it. #{action_for_in_flight_state(state)}"
216
+ end
217
+
218
+ # No editable version exists — create a fresh one. POST returns 201
219
+ # with the new resource in `data`.
220
+ body = {
221
+ data: {
222
+ type: 'appStoreVersions',
223
+ attributes: {
224
+ versionString: @cf_bundle_short_version_string,
225
+ platform: @platform
226
+ },
227
+ relationships: {
228
+ app: { data: { type: 'apps', id: @apple_app_id } }
229
+ }
230
+ }
231
+ }
232
+ created = apple_post_json('/v1/appStoreVersions', body: body, expected_status: 201)
233
+ created.dig('data', 'id')
234
+ end
235
+
236
+ # WHY this list, not just "anything that isn't PREPARE_FOR_SUBMISSION":
237
+ # we enumerate the known in-flight states explicitly so an Apple-side
238
+ # state addition doesn't get silently lumped into a generic bucket. If
239
+ # a new state appears in the wild we'll fall through to the POST and
240
+ # surface Apple's raw error — louder than silently mis-categorising.
241
+ IN_FLIGHT_STATES = %w[
242
+ WAITING_FOR_REVIEW
243
+ IN_REVIEW
244
+ PENDING_DEVELOPER_RELEASE
245
+ PROCESSING_FOR_APP_STORE
246
+ DEVELOPER_REJECTED
247
+ REJECTED
248
+ METADATA_REJECTED
249
+ INVALID_BINARY
250
+ WAITING_FOR_EXPORT_COMPLIANCE
251
+ ACCEPTED
252
+ ].freeze
253
+ private_constant :IN_FLIGHT_STATES
254
+
255
+ # Per-state next-step copy. The CLI surfaces these verbatim, so each
256
+ # line must be self-contained and actionable.
257
+ def action_for_in_flight_state(state)
258
+ case state
259
+ when 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'PROCESSING_FOR_APP_STORE', 'ACCEPTED'
260
+ 'Wait for Apple to finish review, then re-run. To cancel and resubmit, ' \
261
+ "cancel the review in App Store Connect (Apps → your app → 'Cancel')."
262
+ when 'PENDING_DEVELOPER_RELEASE'
263
+ 'The build is approved and waiting for you to release it manually in ' \
264
+ 'App Store Connect. No re-submit is needed; release it there.'
265
+ when 'WAITING_FOR_EXPORT_COMPLIANCE'
266
+ 'Provide export-compliance answers in App Store Connect, then re-run.'
267
+ when 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY'
268
+ 'Bump MARKETING_VERSION in Xcode (e.g. 1.0 → 1.0.1), fix the rejection ' \
269
+ 'cause in App Store Connect, re-archive, and re-run.'
270
+ else
271
+ 'Resolve the version state in App Store Connect, then re-run.'
272
+ end
273
+ end
274
+
275
+ # Step 3 — attach the processed build to the appStoreVersion via the
276
+ # build relationship. PATCH returns 204 No Content on success.
277
+ def attach_build!(version_id, build_id)
278
+ body = { data: { type: 'builds', id: build_id } }
279
+ apple_patch_json("/v1/appStoreVersions/#{version_id}/relationships/build",
280
+ body: body, expected_status: 204)
281
+ end
282
+
283
+ # Step 4 — drive Apple's modern 3-call submit-for-review choreography:
284
+ # (a) POST /v1/reviewSubmissions → create a submission container
285
+ # (b) POST /v1/reviewSubmissionItems → attach the appStoreVersion
286
+ # (c) PATCH /v1/reviewSubmissions/<id> → flip submitted=true
287
+ #
288
+ # WHY 3 calls instead of the legacy one: see class docstring. We map
289
+ # every 4xx in any of the three to SubmissionRejectedError so the CLI
290
+ # rescue contract stays identical — the typed-error surface didn't
291
+ # change, only the wire shape did.
292
+ def submit_for_review_via_review_submissions!(version_id)
293
+ submission_id = create_review_submission!
294
+ create_review_submission_item!(submission_id: submission_id, version_id: version_id)
295
+ finalize_review_submission!(submission_id)
296
+ submission_id
297
+ end
298
+
299
+ # (a) Create the submission container scoped to this app + platform.
300
+ def create_review_submission!
301
+ body = {
302
+ data: {
303
+ type: 'reviewSubmissions',
304
+ attributes: { platform: @platform },
305
+ relationships: {
306
+ app: { data: { type: 'apps', id: @apple_app_id } }
307
+ }
308
+ }
309
+ }
310
+ created = apple_post_json('/v1/reviewSubmissions',
311
+ body: body,
312
+ expected_status: 201,
313
+ rejection_class: SubmissionRejectedError)
314
+ created.dig('data', 'id')
315
+ end
316
+
317
+ # (b) Attach the appStoreVersion to the submission. Apple returns 201
318
+ # on success.
319
+ def create_review_submission_item!(submission_id:, version_id:)
320
+ body = {
321
+ data: {
322
+ type: 'reviewSubmissionItems',
323
+ relationships: {
324
+ reviewSubmission: { data: { type: 'reviewSubmissions', id: submission_id } },
325
+ appStoreVersion: { data: { type: 'appStoreVersions', id: version_id } }
326
+ }
327
+ }
328
+ }
329
+ apple_post_json('/v1/reviewSubmissionItems',
330
+ body: body,
331
+ expected_status: 201,
332
+ rejection_class: SubmissionRejectedError)
333
+ end
334
+
335
+ # (c) Flip `submitted` to true. Apple returns 200 with the updated
336
+ # resource on success.
337
+ def finalize_review_submission!(submission_id)
338
+ body = {
339
+ data: {
340
+ type: 'reviewSubmissions',
341
+ id: submission_id,
342
+ attributes: { submitted: true }
343
+ }
344
+ }
345
+ apple_patch_json("/v1/reviewSubmissions/#{submission_id}",
346
+ body: body,
347
+ expected_status: 200,
348
+ rejection_class: SubmissionRejectedError)
349
+ end
350
+
351
+ def apple_get_json(path, params: {})
352
+ resp = http_conn.get(path) do |req|
353
+ params.each { |k, v| req.params[k.to_s] = v }
354
+ req.headers['Authorization'] = "Bearer #{@jwt}"
355
+ end
356
+ ensure_2xx!(resp, method: :GET, path: path)
357
+ parse_json(resp.body)
358
+ end
359
+
360
+ def apple_post_json(path, body:, expected_status: 201, rejection_class: AppleApiError)
361
+ resp = http_conn.post(path) do |req|
362
+ req.headers['Authorization'] = "Bearer #{@jwt}"
363
+ req.headers['Content-Type'] = 'application/json'
364
+ req.body = JSON.generate(body)
365
+ end
366
+ ensure_status!(resp, expected_status, method: :POST, path: path, rejection_class: rejection_class)
367
+ resp.status == 204 ? {} : parse_json(resp.body)
368
+ end
369
+
370
+ def apple_patch_json(path, body:, expected_status: 204, rejection_class: AppleApiError)
371
+ resp = http_conn.patch(path) do |req|
372
+ req.headers['Authorization'] = "Bearer #{@jwt}"
373
+ req.headers['Content-Type'] = 'application/json'
374
+ req.body = JSON.generate(body)
375
+ end
376
+ ensure_status!(resp, expected_status, method: :PATCH, path: path, rejection_class: rejection_class)
377
+ resp.status == 204 ? {} : parse_json(resp.body)
378
+ end
379
+
380
+ def http_conn
381
+ @http_conn ||= Faraday.new(url: APPLE_ASC_BASE) do |f|
382
+ f.adapter Faraday.default_adapter
383
+ end
384
+ end
385
+
386
+ def ensure_2xx!(resp, method:, path:)
387
+ return if resp.status.between?(200, 299)
388
+
389
+ raise apple_api_error_for(resp, method: method, path: path)
390
+ end
391
+
392
+ def ensure_status!(resp, expected, method:, path:, rejection_class: AppleApiError)
393
+ return if resp.status == expected
394
+
395
+ raise apple_api_error_for(resp, method: method, path: path) if rejection_class == AppleApiError
396
+
397
+ raise rejection_class, "Apple #{method} #{path} returned #{resp.status}: #{resp.body}"
398
+ end
399
+
400
+ # Bundles HTTP status + Retry-After onto the exception so the poll
401
+ # loop can branch on 429s without re-parsing strings.
402
+ def apple_api_error_for(resp, method:, path:)
403
+ AppleApiError.new(
404
+ "Apple #{method} #{path} returned #{resp.status}: #{resp.body}",
405
+ status: resp.status,
406
+ retry_after: resp.headers && resp.headers['Retry-After']
407
+ )
408
+ end
409
+
410
+ def parse_json(body)
411
+ return {} if body.nil? || body.empty?
412
+
413
+ JSON.parse(body)
414
+ rescue JSON::ParserError
415
+ {}
416
+ end
417
+
418
+ def log(message)
419
+ return unless @logger
420
+
421
+ @logger.respond_to?(:puts) ? @logger.puts(message) : warn(message)
422
+ end
423
+
424
+ # `Process.clock_gettime(CLOCK_MONOTONIC)` is immune to wall-clock
425
+ # adjustments (NTP, DST) — the right primitive for "how long has X
426
+ # been running" timeouts.
427
+ def monotonic_now
428
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
429
+ end
430
+ end
431
+ end
432
+ end
@@ -9,6 +9,14 @@ module Mysigner
9
9
  class CredentialsError < UploadError; end
10
10
  class TrackError < UploadError; end
11
11
 
12
+ # Raised when local-only mode is requested but no Google Play credentials
13
+ # are stored in the LocalCredentials store. The message points users at
14
+ # `mysigner onboard --local-only` (mysigner-44) which is what persists
15
+ # them. We refuse to silently fall back to the server path — local-only
16
+ # must fail loud. Defined locally (not shared with AscRestUploader) so
17
+ # each uploader owns its own error contract.
18
+ class MissingLocalCredentialsError < UploadError; end
19
+
12
20
  # Special error for when AAB uploaded but track assignment failed
13
21
  # This carries the version_code so it can be saved to prevent conflicts
14
22
  class PartialUploadError < UploadError
@@ -23,16 +31,74 @@ module Mysigner
23
31
  VALID_TRACKS = %w[internal alpha beta production].freeze
24
32
  SCOPE = 'https://www.googleapis.com/auth/androidpublisher'
25
33
 
34
+ # mysigner-22 follow-up — pre-check the user's project versionCode
35
+ # against what's already on Google Play in local-only mode, where the
36
+ # MySigner server's `highest_version_code` lookup is bypassed. The
37
+ # cheapest authenticated way to ask Google "what's already there" is
38
+ # to insert an edit, list all uploaded bundles (which carry their
39
+ # versionCode), and discard the edit. Inserting an edit is free and
40
+ # has no side effect when never committed.
41
+ #
42
+ # Returns the maximum versionCode across all bundles (Integer), or
43
+ # nil when the app has no bundles yet (very first upload).
44
+ def self.fetch_highest_version_code(package_name:, access_token:)
45
+ require 'google/apis/androidpublisher_v3'
46
+
47
+ service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
48
+ service.authorization = access_token
49
+
50
+ edit = service.insert_edit(package_name, Google::Apis::AndroidpublisherV3::AppEdit.new)
51
+ begin
52
+ bundles_response = service.list_edit_bundles(package_name, edit.id)
53
+ version_codes = Array(bundles_response&.bundles).map(&:version_code).compact
54
+ return nil if version_codes.empty?
55
+
56
+ version_codes.max
57
+ ensure
58
+ # Best-effort cleanup — the edit auto-expires after a week if we
59
+ # leak one, but tidiness is cheap. Swallow errors so a transient
60
+ # cleanup failure can't mask the real return value.
61
+ begin
62
+ service.delete_edit(package_name, edit.id)
63
+ rescue StandardError
64
+ nil
65
+ end
66
+ end
67
+ rescue Google::Apis::ClientError
68
+ # We treat a lookup failure (auth issue, package-not-found) as
69
+ # "unknown" rather than fatal — Google will still reject at upload
70
+ # time with a useful message. This pre-check is best-effort.
71
+ nil
72
+ end
73
+
26
74
  # Phase 0: accepts a short-lived OAuth2 access_token (minted server-side
27
75
  # from the customer's service-account JSON). The JSON no longer leaves
28
76
  # the server. google-api-ruby-client accepts a bare string for
29
77
  # authorization= and sends it as `Authorization: Bearer <token>`.
30
- def initialize(aab_path:, access_token:, package_name:)
78
+ #
79
+ # mysigner-43: when `local_only: true`, `access_token` is optional —
80
+ # the uploader mints one locally from Keychain-backed SA-JSON. The
81
+ # SA-JSON never leaves the user's machine, and no MySigner server
82
+ # credential endpoints are contacted.
83
+ def initialize(aab_path:, package_name:, access_token: nil, local_only: false, play_creds: nil)
31
84
  @aab_path = File.expand_path(aab_path)
32
85
  @access_token = access_token
33
86
  @package_name = package_name
34
-
35
- raise CredentialsError, 'access_token is required' if @access_token.nil? || @access_token.to_s.empty?
87
+ @local_only = local_only
88
+ # mysigner-22 Phase 5 pre-resolved PlayCreds Struct from the
89
+ # CredentialResolver cascade. When nil (legacy / unit tests), we fall
90
+ # back to the resolver with default args (Keychain only) inside
91
+ # local_access_token — preserving existing spec invariants.
92
+ @play_creds = play_creds
93
+
94
+ if @local_only
95
+ # Mint immediately so missing-credentials errors surface at
96
+ # construction time (same DX as the server path's
97
+ # CredentialsError) rather than mid-upload.
98
+ @access_token = local_access_token
99
+ elsif @access_token.nil? || @access_token.to_s.empty?
100
+ raise CredentialsError, 'access_token is required'
101
+ end
36
102
 
37
103
  validate_aab!
38
104
  setup_google_client!
@@ -182,6 +248,32 @@ module Mysigner
182
248
  raise CredentialsError, 'Google API client not installed. Run: gem install google-api-client'
183
249
  end
184
250
 
251
+ # mysigner-43 + mysigner-22 Phase 5: look up the Google Play SA-JSON
252
+ # through the CredentialResolver cascade (flag → env → keychain →
253
+ # project sniff → prompt) and mint a short-lived OAuth2 access_token.
254
+ # The SA-JSON never leaves the process; the MySigner server is never
255
+ # contacted.
256
+ def local_access_token
257
+ require 'mysigner/auth/google_oauth_minter'
258
+ creds = @play_creds || resolve_play_creds
259
+ Mysigner::Auth::GoogleOauthMinter.new(creds.sa_json).mint(scope: SCOPE)
260
+ end
261
+
262
+ def resolve_play_creds
263
+ require 'mysigner/credential_resolver'
264
+ Mysigner::CredentialResolver.resolve_play
265
+ rescue Mysigner::CredentialResolver::CredentialNotFoundError, Mysigner::CredentialResolver::AmbiguousCredentialsError => e
266
+ raise MissingLocalCredentialsError, rewrite_resolver_error(e.message)
267
+ end
268
+
269
+ def rewrite_resolver_error(text)
270
+ if text.start_with?('No usable Google Play credentials found')
271
+ "No local Google Play credentials found via `mysigner onboard --local-only`. #{text}"
272
+ else
273
+ text
274
+ end
275
+ end
276
+
185
277
  def create_edit
186
278
  edit = Google::Apis::AndroidpublisherV3::AppEdit.new
187
279
  @service.insert_edit(@package_name, edit)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mysigner
4
- VERSION = '0.1.7'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/mysigner.rb CHANGED
@@ -6,6 +6,7 @@ end
6
6
 
7
7
  require 'mysigner/version'
8
8
  require 'mysigner/config'
9
+ require 'mysigner/local_credentials'
9
10
  require 'mysigner/client'
10
11
  require 'mysigner/build/detector'
11
12
  require 'mysigner/build/parser'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mysigner
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jurgen Leka
@@ -235,11 +235,10 @@ files:
235
235
  - Rakefile
236
236
  - bin/console
237
237
  - bin/setup
238
- - certificate_.cer
239
238
  - exe/mysigner
240
- - iOS_App_Store_Profile.mobileprovision
241
- - iOS_Distribution_Certificate.cer
242
239
  - lib/mysigner.rb
240
+ - lib/mysigner/auth/asc_jwt_minter.rb
241
+ - lib/mysigner/auth/google_oauth_minter.rb
243
242
  - lib/mysigner/build/android_executor.rb
244
243
  - lib/mysigner/build/android_parser.rb
245
244
  - lib/mysigner/build/configurator.rb
@@ -260,7 +259,9 @@ files:
260
259
  - lib/mysigner/cli/validate_commands.rb
261
260
  - lib/mysigner/client.rb
262
261
  - lib/mysigner/config.rb
262
+ - lib/mysigner/credential_resolver.rb
263
263
  - lib/mysigner/export/exporter.rb
264
+ - lib/mysigner/local_credentials.rb
264
265
  - lib/mysigner/signing/certificate_checker.rb
265
266
  - lib/mysigner/signing/gradle_signing_injector.rb
266
267
  - lib/mysigner/signing/keystore_manager.rb
@@ -269,11 +270,11 @@ files:
269
270
  - lib/mysigner/upload/app_store_automation.rb
270
271
  - lib/mysigner/upload/app_store_submission.rb
271
272
  - lib/mysigner/upload/asc_rest_uploader.rb
273
+ - lib/mysigner/upload/asc_submitter.rb
272
274
  - lib/mysigner/upload/play_store_uploader.rb
273
275
  - lib/mysigner/upload/uploader.rb
274
276
  - lib/mysigner/version.rb
275
277
  - mysigner.gemspec
276
- - profile_.mobileprovision
277
278
  - test_manual.rb
278
279
  homepage: https://mysigner.dev
279
280
  licenses:
data/certificate_.cer DELETED
File without changes
@@ -1 +0,0 @@
1
- Server error
@@ -1 +0,0 @@
1
- Server error
File without changes