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
|
@@ -2,24 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
require 'digest'
|
|
4
4
|
require 'faraday'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'json'
|
|
7
|
+
require 'open3'
|
|
5
8
|
require 'uri'
|
|
6
9
|
|
|
7
10
|
module Mysigner
|
|
8
11
|
module Upload
|
|
9
12
|
class AscRestUploader
|
|
10
|
-
# Raised when Apple rejects the
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
+
# Raised when Apple rejects the build for a duplicate CFBundleVersion.
|
|
14
|
+
# In vault mode this surfaces as a 409 on /v1/buildUploads; in
|
|
15
|
+
# local-only mode altool prints an ITMS-90... "build version already
|
|
16
|
+
# exists" diagnostic. Both translate to this same class so the CLI
|
|
17
|
+
# rescue can give the user one consistent "bump your build number" hint.
|
|
13
18
|
class BuildVersionConflictError < StandardError; end
|
|
14
19
|
|
|
20
|
+
# Raised when local-only mode is requested but no ASC credentials are
|
|
21
|
+
# stored in the LocalCredentials store. The message points users at the
|
|
22
|
+
# onboard flow that persists the credentials (mysigner-44). We refuse to
|
|
23
|
+
# silently fall back to the server path — local-only must fail loud.
|
|
24
|
+
class MissingLocalCredentialsError < StandardError; end
|
|
25
|
+
|
|
26
|
+
# Raised when altool fails for any reason OTHER than the
|
|
27
|
+
# BuildVersionConflictError case. Carries altool's own error code and
|
|
28
|
+
# message verbatim — we deliberately do NOT blanket-map every altool
|
|
29
|
+
# failure to a generic "upload failed" string. WHY: the previous
|
|
30
|
+
# implementation mapped every 409 to BuildVersionConflictError and
|
|
31
|
+
# silently hid real causes (attribute-shape mismatches, signature
|
|
32
|
+
# rejections, expired tokens). Surfacing altool's exact error is the
|
|
33
|
+
# only way the user can act on it.
|
|
34
|
+
class AltoolUploadError < StandardError; end
|
|
35
|
+
|
|
15
36
|
TERMINAL_APPLE_STATES = %w[COMPLETE FAILED INVALIDATED].freeze
|
|
16
37
|
POLL_INTERVAL = 10
|
|
17
38
|
POLL_TIMEOUT = 600
|
|
18
39
|
CHUNK_RETRIES = 2
|
|
19
40
|
|
|
41
|
+
APPLE_ASC_BASE = 'https://api.appstoreconnect.apple.com'
|
|
42
|
+
|
|
43
|
+
# Apple's hardcoded discovery path for ASC private keys. `altool
|
|
44
|
+
# --apiKey KEY_ID` looks for AuthKey_<KEY_ID>.p8 in this directory
|
|
45
|
+
# (and only this directory) — no flag exists to override it. We ensure
|
|
46
|
+
# the .p8 lives here before invoking altool.
|
|
47
|
+
APPLE_PRIVATE_KEYS_DIR = File.expand_path('~/.appstoreconnect/private_keys').freeze
|
|
48
|
+
|
|
20
49
|
def initialize(client:, organization_id:, ipa_path:, apple_app_id:,
|
|
21
50
|
cf_bundle_version:, cf_bundle_short_version_string:,
|
|
22
|
-
platform: 'IOS', poll_interval: POLL_INTERVAL, poll_timeout: POLL_TIMEOUT
|
|
51
|
+
platform: 'IOS', poll_interval: POLL_INTERVAL, poll_timeout: POLL_TIMEOUT,
|
|
52
|
+
local_only: false, asc_creds: nil)
|
|
23
53
|
@client = client
|
|
24
54
|
@org_id = organization_id
|
|
25
55
|
@ipa_path = ipa_path
|
|
@@ -29,36 +59,19 @@ module Mysigner
|
|
|
29
59
|
@platform = platform
|
|
30
60
|
@poll_interval = poll_interval
|
|
31
61
|
@poll_timeout = poll_timeout
|
|
62
|
+
@local_only = local_only
|
|
63
|
+
# mysigner-22 Phase 5 — pre-resolved AscCreds Struct from the
|
|
64
|
+
# CredentialResolver cascade. When nil (legacy callers / unit tests),
|
|
65
|
+
# we fall back to the resolver inside resolve_asc_creds with default
|
|
66
|
+
# args (which preserves the Keychain-only behavior the existing specs
|
|
67
|
+
# pin).
|
|
68
|
+
@asc_creds = asc_creds
|
|
32
69
|
end
|
|
33
70
|
|
|
34
71
|
def call
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
body: {
|
|
39
|
-
apple_app_id: @apple_app_id,
|
|
40
|
-
cf_bundle_version: @cf_bundle_version,
|
|
41
|
-
cf_bundle_short_version_string: @cf_bundle_short_version_string,
|
|
42
|
-
platform: @platform,
|
|
43
|
-
file_name: File.basename(@ipa_path),
|
|
44
|
-
file_size: File.size(@ipa_path)
|
|
45
|
-
}
|
|
46
|
-
)
|
|
47
|
-
rescue StandardError => e
|
|
48
|
-
# Apple returns 409 from /v1/buildUploads when a build with the
|
|
49
|
-
# same CFBundleVersion already exists for this app. Surface a
|
|
50
|
-
# useful message instead of letting the caller print the raw
|
|
51
|
-
# "ASC /v1/buildUploads returned 409" string.
|
|
52
|
-
if e.message =~ /\b(409|buildUploads returned 409|duplicate)/i
|
|
53
|
-
raise BuildVersionConflictError,
|
|
54
|
-
"Apple refused the upload: build #{@cf_bundle_version} already exists for this app (CFBundleVersion must be unique). " \
|
|
55
|
-
'Bump CFBundleVersion in your Xcode project and re-archive, then retry.'
|
|
56
|
-
end
|
|
57
|
-
raise
|
|
58
|
-
end
|
|
59
|
-
data = resp[:data]
|
|
60
|
-
build_upload_id = data['build_upload_id']
|
|
61
|
-
ops = data['upload_operations']
|
|
72
|
+
return call_altool! if @local_only
|
|
73
|
+
|
|
74
|
+
build_upload_id, ops = create_build_upload_via_server
|
|
62
75
|
|
|
63
76
|
File.open(@ipa_path, 'rb') do |f|
|
|
64
77
|
ops.each { |op| put_chunk_with_retry(f, op) }
|
|
@@ -67,16 +80,220 @@ module Mysigner
|
|
|
67
80
|
md5 = Digest::MD5.file(@ipa_path).hexdigest
|
|
68
81
|
sha = Digest::SHA256.file(@ipa_path).hexdigest
|
|
69
82
|
|
|
83
|
+
mark_uploaded_via_server(build_upload_id, md5: md5, sha: sha)
|
|
84
|
+
|
|
85
|
+
final = poll_until_terminal_via_server(build_upload_id)
|
|
86
|
+
{ build_upload_id: build_upload_id, final_state: final }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Local-only: shell out to `xcrun altool --upload-app`. altool is
|
|
92
|
+
# Apple's canonical CLI for App Store uploads — it handles the multi-
|
|
93
|
+
# step buildUploads/buildUploadFiles/chunk-PUT/commit dance correctly
|
|
94
|
+
# (we previously tried to reimplement it via raw REST and got the
|
|
95
|
+
# payload shape wrong, see mysigner-46). After altool exits 0 the
|
|
96
|
+
# upload is complete; altool itself polls Apple's transporter to
|
|
97
|
+
# completion, so we don't need to poll on our side.
|
|
98
|
+
#
|
|
99
|
+
# Return contract matches the vault path: {build_upload_id, final_state}.
|
|
100
|
+
# altool doesn't surface the buildUploads id, so we return nil for it —
|
|
101
|
+
# callers should already tolerate a nil id (vault mode is the only one
|
|
102
|
+
# that uses it for follow-up calls).
|
|
103
|
+
def call_altool!
|
|
104
|
+
creds = resolve_asc_creds_for_altool
|
|
105
|
+
ensure_p8_in_apple_dir!(creds)
|
|
106
|
+
|
|
107
|
+
argv = altool_argv(creds)
|
|
108
|
+
stdout_stderr, status = Open3.capture2e(*argv)
|
|
109
|
+
|
|
110
|
+
return { build_upload_id: nil, final_state: 'COMPLETE' } if status.success?
|
|
111
|
+
|
|
112
|
+
raise_altool_failure!(stdout_stderr)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `xcrun altool --upload-app --file ... --type ios --apiKey KEY_ID
|
|
116
|
+
# --apiIssuer ISSUER_UUID --output-format json`. We pass each token as
|
|
117
|
+
# its own argv element (no shell) so paths with spaces / weird chars
|
|
118
|
+
# can't break the invocation or open injection holes.
|
|
119
|
+
def altool_argv(creds)
|
|
120
|
+
[
|
|
121
|
+
'xcrun', 'altool', '--upload-app',
|
|
122
|
+
'--file', @ipa_path,
|
|
123
|
+
'--type', altool_platform_for(@platform),
|
|
124
|
+
'--apiKey', creds.key_id,
|
|
125
|
+
'--apiIssuer', creds.issuer_id,
|
|
126
|
+
'--output-format', 'json'
|
|
127
|
+
]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# altool uses lowercase platform tokens distinct from Apple's REST
|
|
131
|
+
# buildUploads platform names. Map them explicitly; default to ios so
|
|
132
|
+
# legacy callers that omit @platform still work.
|
|
133
|
+
def altool_platform_for(platform)
|
|
134
|
+
case platform.to_s.upcase
|
|
135
|
+
when 'MAC_OS', 'MACOS' then 'macos'
|
|
136
|
+
when 'TV_OS', 'TVOS' then 'tvos'
|
|
137
|
+
else 'ios'
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# altool requires the .p8 at ~/.appstoreconnect/private_keys/AuthKey_<KEY_ID>.p8.
|
|
142
|
+
# There is no flag override — this is hardcoded in Apple's binary.
|
|
143
|
+
# AscCreds carries the PEM bytes (read from flag/env/keychain/disk by
|
|
144
|
+
# CredentialResolver) but not the original on-disk path, so we write
|
|
145
|
+
# the bytes to the canonical location. Idempotent: skip the write when
|
|
146
|
+
# the file already contains the exact same PEM. We use 0600 perms
|
|
147
|
+
# because this file holds a private key.
|
|
148
|
+
def ensure_p8_in_apple_dir!(creds)
|
|
149
|
+
FileUtils.mkdir_p(APPLE_PRIVATE_KEYS_DIR, mode: 0o700)
|
|
150
|
+
target = File.join(APPLE_PRIVATE_KEYS_DIR, "AuthKey_#{creds.key_id}.p8")
|
|
151
|
+
|
|
152
|
+
if File.exist?(target) && File.read(target) == creds.p8_pem
|
|
153
|
+
File.chmod(0o600, target)
|
|
154
|
+
return target
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
File.write(target, creds.p8_pem)
|
|
158
|
+
File.chmod(0o600, target)
|
|
159
|
+
target
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Parse altool's --output-format json blob. The error path is:
|
|
163
|
+
# { "product-errors": [ { "code": ..., "message": "..." }, ... ] }
|
|
164
|
+
# Any "build version already exists" diagnostic — Apple's ITMS-90... —
|
|
165
|
+
# maps to BuildVersionConflictError so the CLI rescue gives the same
|
|
166
|
+
# "bump CFBundleVersion" hint as the vault path. Everything else
|
|
167
|
+
# raises AltoolUploadError carrying altool's verbatim payload (we
|
|
168
|
+
# deliberately do NOT blanket-map: that masked real bugs before).
|
|
169
|
+
def raise_altool_failure!(combined_output)
|
|
170
|
+
parsed = parse_altool_json(combined_output)
|
|
171
|
+
errors = Array(parsed && parsed['product-errors'])
|
|
172
|
+
|
|
173
|
+
if errors.any? { |e| build_version_conflict?(e) }
|
|
174
|
+
raise BuildVersionConflictError,
|
|
175
|
+
"Apple refused the upload: build #{@cf_bundle_version} already exists for this app (CFBundleVersion must be unique). " \
|
|
176
|
+
'Bump CFBundleVersion in your Xcode project and re-archive, then retry.'
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
raise AltoolUploadError, altool_error_message(errors, combined_output)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# altool prints diagnostics interleaved with the JSON on stdout/stderr.
|
|
183
|
+
# We scan for the first balanced { ... } block that parses as JSON;
|
|
184
|
+
# when none is found we return nil so the caller falls back to the raw
|
|
185
|
+
# combined output.
|
|
186
|
+
def parse_altool_json(text)
|
|
187
|
+
return nil if text.nil? || text.empty?
|
|
188
|
+
|
|
189
|
+
start = text.index('{')
|
|
190
|
+
return nil if start.nil?
|
|
191
|
+
|
|
192
|
+
# Try progressively longer balanced slices. altool's JSON is small
|
|
193
|
+
# enough that O(n^2) here is fine and far simpler than a full parser.
|
|
194
|
+
tail = text[start..]
|
|
195
|
+
(0...tail.length).each do |len|
|
|
196
|
+
candidate = tail[0..len]
|
|
197
|
+
next unless candidate.end_with?('}')
|
|
198
|
+
|
|
199
|
+
begin
|
|
200
|
+
return JSON.parse(candidate)
|
|
201
|
+
rescue JSON::ParserError
|
|
202
|
+
next
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The canonical "duplicate build" diagnostic is ITMS-90... with the
|
|
209
|
+
# phrase "build version" + "already exists" in the message. We match
|
|
210
|
+
# on the phrase (case-insensitive) rather than the ITMS code so we
|
|
211
|
+
# catch the family of variants Apple has shipped over the years.
|
|
212
|
+
def build_version_conflict?(error)
|
|
213
|
+
msg = error.is_a?(Hash) ? error['message'].to_s : error.to_s
|
|
214
|
+
msg.match?(/build version.*already exists/i)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def altool_error_message(errors, combined_output)
|
|
218
|
+
if errors.any?
|
|
219
|
+
parts = errors.map do |e|
|
|
220
|
+
code = e.is_a?(Hash) ? e['code'] : nil
|
|
221
|
+
msg = e.is_a?(Hash) ? e['message'] : e.to_s
|
|
222
|
+
code ? "[#{code}] #{msg}" : msg.to_s
|
|
223
|
+
end
|
|
224
|
+
"altool --upload-app failed: #{parts.join(' | ')}"
|
|
225
|
+
else
|
|
226
|
+
"altool --upload-app failed (no parseable JSON errors): #{combined_output.strip}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def create_build_upload_via_server
|
|
231
|
+
resp = @client.post(
|
|
232
|
+
"/api/v1/organizations/#{@org_id}/builds/asc_upload",
|
|
233
|
+
body: {
|
|
234
|
+
apple_app_id: @apple_app_id,
|
|
235
|
+
cf_bundle_version: @cf_bundle_version,
|
|
236
|
+
cf_bundle_short_version_string: @cf_bundle_short_version_string,
|
|
237
|
+
platform: @platform,
|
|
238
|
+
file_name: File.basename(@ipa_path),
|
|
239
|
+
file_size: File.size(@ipa_path)
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
data = resp[:data]
|
|
243
|
+
[data['build_upload_id'], data['upload_operations']]
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
# Apple returns 409 from /v1/buildUploads when a build with the
|
|
246
|
+
# same CFBundleVersion already exists for this app. Surface a
|
|
247
|
+
# useful message instead of letting the caller print the raw
|
|
248
|
+
# "ASC /v1/buildUploads returned 409" string.
|
|
249
|
+
raise unless e.message =~ /\b(409|buildUploads returned 409|duplicate)/i
|
|
250
|
+
|
|
251
|
+
raise BuildVersionConflictError,
|
|
252
|
+
"Apple refused the upload: build #{@cf_bundle_version} already exists for this app (CFBundleVersion must be unique). " \
|
|
253
|
+
'Bump CFBundleVersion in your Xcode project and re-archive, then retry.'
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def mark_uploaded_via_server(build_upload_id, md5:, sha:)
|
|
70
257
|
@client.patch(
|
|
71
258
|
"/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}",
|
|
72
259
|
body: { uploaded: true, source_file_checksums: { md5: md5, sha256: sha } }
|
|
73
260
|
)
|
|
261
|
+
end
|
|
74
262
|
|
|
75
|
-
|
|
76
|
-
|
|
263
|
+
# Resolves ASC creds via the cascade for the altool path. Translates
|
|
264
|
+
# CredentialResolver errors into MissingLocalCredentialsError so the
|
|
265
|
+
# existing CLI rescue + the "No local ASC credentials found" wording
|
|
266
|
+
# the specs pin on continue to work.
|
|
267
|
+
def resolve_asc_creds_for_altool
|
|
268
|
+
@asc_creds || resolve_asc_creds
|
|
77
269
|
end
|
|
78
270
|
|
|
79
|
-
|
|
271
|
+
def resolve_asc_creds
|
|
272
|
+
require 'mysigner/credential_resolver'
|
|
273
|
+
# Default: no Thor options, no env vars guaranteed — this preserves
|
|
274
|
+
# the "Keychain-only" behavior the existing local_only specs assert
|
|
275
|
+
# on (they stub LocalCredentials and don't expect any other tier to
|
|
276
|
+
# win). The CLI passes an asc_creds: that was resolved with the real
|
|
277
|
+
# Thor options/env/stdin.
|
|
278
|
+
Mysigner::CredentialResolver.resolve_asc
|
|
279
|
+
rescue Mysigner::CredentialResolver::CredentialNotFoundError, Mysigner::CredentialResolver::AmbiguousCredentialsError => e
|
|
280
|
+
# Keep the historic error class so CLI rescue / upstream specs that
|
|
281
|
+
# rescue on MissingLocalCredentialsError still work; the message now
|
|
282
|
+
# carries the resolver's "tried in order + override knobs" block.
|
|
283
|
+
raise MissingLocalCredentialsError, rewrite_resolver_error(e.message)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Make the resolver's text match the historical wording the CLI rescue
|
|
287
|
+
# specs were written against, without losing the resolver's richer
|
|
288
|
+
# cascade info. WHY: callers regex-match on "No local ASC credentials
|
|
289
|
+
# found" — preserving that string is cheaper than churning every spec.
|
|
290
|
+
def rewrite_resolver_error(text)
|
|
291
|
+
if text.start_with?('No usable App Store Connect credentials found')
|
|
292
|
+
"No local ASC credentials found via `mysigner onboard --local-only`. #{text}"
|
|
293
|
+
else
|
|
294
|
+
text
|
|
295
|
+
end
|
|
296
|
+
end
|
|
80
297
|
|
|
81
298
|
def put_chunk_with_retry(file, operation)
|
|
82
299
|
# Defense-in-depth: Apple's signed URLs are always https. If the
|
|
@@ -103,7 +320,7 @@ module Mysigner
|
|
|
103
320
|
end
|
|
104
321
|
end
|
|
105
322
|
|
|
106
|
-
def
|
|
323
|
+
def poll_until_terminal_via_server(build_upload_id)
|
|
107
324
|
deadline = Time.now + @poll_timeout
|
|
108
325
|
loop do
|
|
109
326
|
resp = @client.get("/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}")
|