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.
- checksums.yaml +4 -4
- data/.gitignore +25 -0
- data/.rubocop_todo.yml +6 -1
- data/CHANGELOG.md +92 -0
- data/Gemfile.lock +7 -7
- data/README.md +94 -1
- data/exe/mysigner +55 -1
- data/lib/mysigner/auth/asc_jwt_minter.rb +68 -0
- data/lib/mysigner/auth/google_oauth_minter.rb +89 -0
- data/lib/mysigner/cleanup/private_keys_purger.rb +0 -1
- data/lib/mysigner/cli/auth_commands.rb +355 -5
- data/lib/mysigner/cli/build_commands.rb +540 -267
- data/lib/mysigner/cli/concerns/helpers.rb +135 -0
- data/lib/mysigner/cli.rb +3 -2
- data/lib/mysigner/config.rb +40 -1
- data/lib/mysigner/credential_resolver.rb +1099 -0
- data/lib/mysigner/local_credentials.rb +281 -0
- data/lib/mysigner/signing/keystore_manager.rb +7 -10
- data/lib/mysigner/signing/validator.rb +20 -9
- data/lib/mysigner/upload/asc_rest_uploader.rb +252 -35
- data/lib/mysigner/upload/asc_submitter.rb +432 -0
- data/lib/mysigner/upload/play_store_uploader.rb +95 -3
- data/lib/mysigner/version.rb +1 -1
- data/lib/mysigner.rb +1 -0
- metadata +6 -5
- data/certificate_.cer +0 -0
- data/iOS_App_Store_Profile.mobileprovision +0 -1
- data/iOS_Distribution_Certificate.cer +0 -1
- data/profile_.mobileprovision +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/mysigner/version.rb
CHANGED
data/lib/mysigner.rb
CHANGED
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.
|
|
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
|
data/profile_.mobileprovision
DELETED
|
File without changes
|