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.
@@ -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 /v1/buildUploads POST with a 409
11
- # (duplicate CFBundleVersion). Callers should translate this into a
12
- # "bump your build number" hint rather than a generic "Unexpected error".
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
- begin
36
- resp = @client.post(
37
- "/api/v1/organizations/#{@org_id}/builds/asc_upload",
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
- final = poll_until_terminal(build_upload_id)
76
- { build_upload_id: build_upload_id, final_state: final }
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
- private
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 poll_until_terminal(build_upload_id)
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}")