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.
@@ -73,6 +73,35 @@ module Mysigner
73
73
  desc: 'Scheduled release date (ISO 8601, e.g., 2026-02-01T10:00:00Z)'
74
74
  method_option :auto_submit, type: :boolean,
75
75
  desc: 'Submit for App Store review after upload. Defaults to dashboard CLI Defaults, else true for ship appstore. Use --no-auto-submit to skip.'
76
+ # mysigner-22 Phase 5 — local-only credential auto-discovery
77
+ # overrides. These layer on top of env vars / Keychain / disk scan;
78
+ # see Mysigner::CredentialResolver. They are no-ops in vault mode.
79
+ method_option :asc_key_path, type: :string, banner: 'PATH',
80
+ desc: 'Path to your App Store Connect .p8 key (local-only mode)'
81
+ method_option :asc_key_id, type: :string, banner: 'KEY_ID',
82
+ desc: 'App Store Connect Key ID (local-only mode)'
83
+ method_option :asc_issuer_id, type: :string, banner: 'UUID',
84
+ desc: 'App Store Connect Issuer ID (local-only mode)'
85
+ method_option :play_credentials, type: :string, banner: 'PATH',
86
+ desc: 'Path to Google Play service-account JSON (local-only mode)'
87
+ # mysigner-22 Phase 7 — Android keystore overrides for --local-only.
88
+ # In vault mode these are ignored; the cascade only fires when
89
+ # local_only? is true.
90
+ method_option :keystore_path, type: :string, banner: 'PATH',
91
+ desc: 'Path to Android signing keystore .jks/.keystore (local-only mode)'
92
+ method_option :keystore_password, type: :string, banner: 'PWD',
93
+ desc: 'Android keystore password (local-only mode)'
94
+ method_option :key_alias, type: :string, banner: 'ALIAS',
95
+ desc: 'Android key alias inside the keystore (local-only mode)'
96
+ method_option :key_password, type: :string, banner: 'PWD',
97
+ desc: 'Android key password (defaults to keystore password) (local-only mode)'
98
+ # mysigner-22 — explicit Apple-side app id override for local-only
99
+ # mode. When the bundleId lookup against Apple's /v1/apps returns
100
+ # zero or multiple matches, the user can short-circuit it by passing
101
+ # the exact appstoreconnect app id from the URL of their app page
102
+ # (e.g. https://appstoreconnect.apple.com/apps/<APPLE_APP_ID>/...).
103
+ method_option :apple_id, type: :string, banner: 'APPLE_APP_ID',
104
+ desc: 'App Store Connect app id (overrides bundleId lookup in --local-only mode)'
76
105
  def ship(target)
77
106
  ios_targets = %w[testflight appstore]
78
107
  android_targets = %w[internal alpha beta production]
@@ -115,6 +144,12 @@ module Mysigner
115
144
 
116
145
  is_appstore = (target == 'appstore')
117
146
 
147
+ # mysigner-42 — when the user opts into local-only for `ship`
148
+ # (testflight/appstore), surface the banner once so they know their
149
+ # .p8 won't traverse the MySigner server. The uploader itself
150
+ # enforces the contract below.
151
+ emit_local_only_banner if local_only?
152
+
118
153
  config = load_config
119
154
  client = create_client(config)
120
155
 
@@ -191,28 +226,38 @@ module Mysigner
191
226
  project_team_id = parser.team_id(target_name, options[:configuration])
192
227
 
193
228
  if !team_id_to_use && (project_team_id.nil? || project_team_id.empty?)
194
- say '🔍 No team set in project, fetching from My Signer...', :yellow
229
+ # mysigner-22 local-only mode cannot read team-id from the
230
+ # MySigner org record (no client). Surface a useful hint
231
+ # instead of letting xcodebuild fail later with a generic
232
+ # signing error.
233
+ if local_only?
234
+ say '⚠️ No team set in project and --local-only mode (cannot fetch from MySigner).', :yellow
235
+ say ' Pass --team <TEAM_ID> or set DEVELOPMENT_TEAM in your Xcode project.', :yellow
236
+ else
237
+ say '🔍 No team set in project, fetching from My Signer...', :yellow
195
238
 
196
- begin
197
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
198
- api_team_id = org_response.dig(:data,
199
- 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
239
+ begin
240
+ org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
241
+ api_team_id = org_response.dig(:data,
242
+ 'app_store_connect_team_id') || org_response['app_store_connect_team_id']
200
243
 
201
- if api_team_id && !api_team_id.empty?
202
- team_id_to_use = api_team_id
203
- say "✓ Using team from My Signer: #{api_team_id}", :green
204
- else
205
- say '⚠️ No team ID configured in My Signer', :yellow
244
+ if api_team_id && !api_team_id.empty?
245
+ team_id_to_use = api_team_id
246
+ say "✓ Using team from My Signer: #{api_team_id}", :green
247
+ else
248
+ say '⚠️ No team ID configured in My Signer', :yellow
249
+ end
250
+ rescue StandardError => e
251
+ say "⚠️ Failed to fetch team from API: #{e.message}", :yellow
206
252
  end
207
- rescue StandardError => e
208
- say "⚠️ Failed to fetch team from API: #{e.message}", :yellow
209
253
  end
210
254
  end
211
255
  say ''
212
256
 
213
257
  # Pre-build validation
214
258
  say '🔍 Validating signing setup...', :cyan
215
- validator = Signing::Validator.new(parser, target_name, options[:configuration], team_id: team_id_to_use)
259
+ validator = Signing::Validator.new(parser, target_name, options[:configuration],
260
+ team_id: team_id_to_use, local_only: local_only?)
216
261
  validator.validate!
217
262
 
218
263
  executor = Build::Executor.new(project_info, parser)
@@ -254,9 +299,14 @@ module Mysigner
254
299
  say "📦 IPA: #{ipa_path}", :cyan
255
300
  say ''
256
301
 
257
- # STEP 2.5: Get current latest build (BEFORE upload) - App Store only
302
+ # STEP 2.5: Get current latest build (BEFORE upload) - App Store only.
303
+ # mysigner-22 — in local-only mode every probe here was a
304
+ # MySigner-server call. We skip the whole block: Apple itself is
305
+ # the source of truth for whether the upload succeeded
306
+ # (AscRestUploader polls /v1/buildUploads directly), so the
307
+ # "did a new build appear server-side" comparison is moot.
258
308
  latest_build_before_upload = nil
259
- if is_appstore
309
+ if is_appstore && !local_only?
260
310
  say '=' * 80, :cyan
261
311
  say 'Getting Current Latest Build', :cyan
262
312
  say '=' * 80, :cyan
@@ -306,101 +356,113 @@ module Mysigner
306
356
 
307
357
  upload_start = Time.now
308
358
 
309
- if ENV['MYSIGNER_USE_LEGACY_ASC'] == '1'
310
- # LEGACY PATH — altool with server-fetched .p8. Will be removed in next release.
311
- say '⚠️ MYSIGNER_USE_LEGACY_ASC=1 — using legacy altool upload path (deprecated).', :yellow
312
-
313
- # Fetch App Store Connect credentials
314
- say '🔐 Fetching App Store Connect credentials...', :yellow
315
-
316
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
317
- org_data = org_response[:data]
318
-
319
- unless org_data['app_store_connect_configured']
320
- say ''
321
- say '✗ App Store Connect credentials not configured', :red
322
- say ''
323
- say 'Quick fix:', :cyan
324
- say ' mysigner doctor # Auto-configure now', :green
325
- say ''
326
- say 'Or manually:', :cyan
327
- say ' 1. Run: mysigner onboard'
328
- say ' 2. Follow Step 5 to add credentials'
329
- say ''
330
- exit 1
331
- end
359
+ # ASC REST Build Upload API. No .p8 ever leaves the server in
360
+ # vault mode; local-only mode delegates to altool via the
361
+ # uploader itself.
362
+ require_relative '../upload/asc_rest_uploader'
363
+
364
+ # In local-only mode, pre-resolve ASC creds via the cascade
365
+ # (flag → env → keychain → ~/.appstoreconnect → prompt) BEFORE
366
+ # the app-id lookup, because the lookup itself needs a JWT.
367
+ # The uploader will mint the JWT from these; no LocalCredentials
368
+ # round-trip happens inside it. In vault mode this is nil and
369
+ # the uploader takes the server-mediated path unchanged.
370
+ asc_creds_for_uploader = (resolve_local_asc_creds_or_exit if local_only?)
371
+
372
+ # Resolve apple_app_id.
373
+ # - vault mode: MySigner /apple_apps lookup (may have been
374
+ # pre-fetched in the appstore sync block above).
375
+ # - local-only: explicit --apple-id wins; otherwise hit Apple's
376
+ # /v1/apps?filter[bundleId]= directly.
377
+ apple_app_id =
378
+ if local_only?
379
+ resolve_apple_app_id_local!(
380
+ bundle_id: bundle_id,
381
+ apple_id_override: options[:apple_id],
382
+ asc_creds: asc_creds_for_uploader
383
+ )
384
+ else
385
+ if !defined?(app) || app.nil?
386
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
387
+ params: { bundle_id: bundle_id })
388
+ app = Array(app_response.dig(:data, 'data', 'apps')).first
389
+ end
332
390
 
333
- api_key = org_data['app_store_connect_key_id']
334
- api_issuer = org_data['app_store_connect_issuer_id']
335
- private_key = org_data['app_store_connect_private_key']
391
+ unless app && app['id']
392
+ say "✗ App not found in MySigner for bundle_id: #{bundle_id}", :red
393
+ say 'Run: mysigner sync ios', :yellow
394
+ exit 1
395
+ end
336
396
 
337
- unless api_key && api_issuer && private_key
338
- say '✗ Error: Invalid credentials received from API', :red
339
- exit 1
397
+ app['id']
340
398
  end
341
399
 
342
- say '✓ Credentials loaded', :green
343
- say ''
400
+ # Read version info from the built IPA
401
+ ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
402
+ cf_version = ipa_info[:cf_bundle_version] || '1'
403
+ cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
344
404
 
345
- # Upload (Uploader writes .p8 to Dir.mktmpdir + cleans up in ensure)
346
- uploader = Upload::Uploader.new(
347
- ipa_path,
348
- api_key: api_key,
349
- api_issuer: api_issuer,
350
- private_key: private_key
351
- )
405
+ say "📤 Uploading via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
352
406
 
353
- uploader.upload!(wait_for_processing: options[:wait])
354
- else
355
- # DEFAULT PATH ASC REST Build Upload API. No .p8 ever leaves the server.
356
- require_relative '../upload/asc_rest_uploader'
357
-
358
- # Resolve apple_app_id from bundle_id (may already have been fetched for is_appstore)
359
- if !defined?(app) || app.nil?
360
- app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
361
- params: { bundle_id: bundle_id })
362
- app = Array(app_response.dig(:data, 'data', 'apps')).first
363
- end
364
-
365
- unless app && app['id']
366
- say "✗ App not found in MySigner for bundle_id: #{bundle_id}", :red
367
- say 'Run: mysigner sync ios', :yellow
368
- exit 1
369
- end
407
+ rest = Mysigner::Upload::AscRestUploader.new(
408
+ client: client,
409
+ organization_id: local_only? ? nil : config.current_organization_id,
410
+ ipa_path: ipa_path,
411
+ apple_app_id: apple_app_id,
412
+ cf_bundle_version: cf_version,
413
+ cf_bundle_short_version_string: cf_short,
414
+ platform: 'IOS',
415
+ local_only: local_only?,
416
+ asc_creds: asc_creds_for_uploader
417
+ )
370
418
 
371
- # Read version info from the built IPA
372
- ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
373
- cf_version = ipa_info[:cf_bundle_version] || '1'
374
- cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
419
+ result = rest.call
420
+ case result[:final_state]
421
+ when 'COMPLETE'
422
+ say '✓ Upload complete — Apple accepted the build', :green
423
+ when 'FAILED', 'INVALIDATED'
424
+ say "✗ Apple rejected the upload: #{result[:final_state]}", :red
425
+ exit 1
426
+ when 'TIMEOUT'
427
+ say '⚠ Apple is still processing — check App Store Connect.', :yellow
428
+ end
375
429
 
376
- say "📤 Uploading via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
430
+ timings[:upload] = Time.now - upload_start
377
431
 
378
- rest = Mysigner::Upload::AscRestUploader.new(
379
- client: client,
380
- organization_id: config.current_organization_id,
381
- ipa_path: ipa_path,
382
- apple_app_id: app['id'],
383
- cf_bundle_version: cf_version,
384
- cf_bundle_short_version_string: cf_short,
385
- platform: 'IOS'
386
- )
432
+ # STEP 4: Submit for App Store Review (appstore only).
433
+ # mysigner-22 follow-up — local-only now drives the same
434
+ # 4-step Apple REST submission flow the vault path drives via
435
+ # MySigner, using the JWT minted from the user's local .p8.
436
+ # Gated by --auto-submit (default true, --no-auto-submit opts
437
+ # out) so users who want "upload only, finish in dashboard"
438
+ # still have that path.
439
+ if is_appstore && local_only?
440
+ auto_submit_default = true
441
+ auto_submit = options.key?(:auto_submit) ? options[:auto_submit] : auto_submit_default
387
442
 
388
- result = rest.call
389
- case result[:final_state]
390
- when 'COMPLETE'
391
- say ' Upload complete Apple accepted the build', :green
392
- when 'FAILED', 'INVALIDATED'
393
- say "✗ Apple rejected the upload: #{result[:final_state]}", :red
394
- exit 1
395
- when 'TIMEOUT'
396
- say '⚠ Apple is still processing — check App Store Connect.', :yellow
443
+ say ''
444
+ say '=' * 80, :cyan
445
+ if auto_submit
446
+ say '[4/4] Submit for App Store Review (--local-only)', :cyan
447
+ say '=' * 80, :cyan
448
+ say ''
449
+ submit_appstore_local!(
450
+ asc_creds: asc_creds_for_uploader,
451
+ apple_app_id: apple_app_id,
452
+ cf_bundle_version: cf_version,
453
+ cf_bundle_short_version_string: cf_short
454
+ )
455
+ else
456
+ say '[4/4] Submit for App Store Review (skipped: --no-auto-submit)', :cyan
457
+ say '=' * 80, :cyan
458
+ say ''
459
+ say '⚠️ Auto-submit disabled. The build is uploaded; finish the submission at:', :yellow
460
+ say ' https://appstoreconnect.apple.com/apps', :cyan
461
+ say ''
397
462
  end
398
463
  end
399
464
 
400
- timings[:upload] = Time.now - upload_start
401
-
402
- # STEP 4: Submit for App Store Review (appstore only)
403
- if is_appstore
465
+ if is_appstore && !local_only?
404
466
  say ''
405
467
  say '=' * 80, :cyan
406
468
  say '[4/5] Waiting for Apple to Process Build', :cyan
@@ -683,6 +745,14 @@ module Mysigner
683
745
  say "✗ #{e.message}", :red
684
746
  say ''
685
747
  exit 1
748
+ rescue Mysigner::Upload::AscRestUploader::MissingLocalCredentialsError => e
749
+ # mysigner-42 — local-only requested but no credentials stored.
750
+ # Fail loud with a non-stack-trace message; the message already
751
+ # tells the user where to store the credentials.
752
+ say ''
753
+ say "✗ #{e.message}", :red
754
+ say ''
755
+ exit 1
686
756
  rescue StandardError => e
687
757
  say ''
688
758
  say '=' * 80, :red
@@ -704,8 +774,138 @@ module Mysigner
704
774
  end
705
775
 
706
776
  no_commands do
777
+ # mysigner-22 — local-only equivalent of the MySigner `/apple_apps`
778
+ # lookup. Calls Apple's /v1/apps?filter[bundleId]= directly with
779
+ # the resolved ASC JWT and returns the matched `id` (string).
780
+ # Honors --apple-id as an unconditional override (skip the lookup
781
+ # entirely). Exits 1 with a clear pointer to --apple-id on zero or
782
+ # multiple matches so the user has a one-knob fix.
783
+ def resolve_apple_app_id_local!(bundle_id:, apple_id_override:, asc_creds:)
784
+ return apple_id_override.to_s if apple_id_override && !apple_id_override.empty?
785
+
786
+ require 'mysigner/auth/asc_jwt_minter'
787
+ require 'faraday'
788
+ require 'json'
789
+ require 'uri'
790
+
791
+ jwt = Mysigner::Auth::AscJwtMinter.new(
792
+ key_id: asc_creds.key_id,
793
+ issuer_id: asc_creds.issuer_id,
794
+ p8_pem: asc_creds.p8_pem
795
+ ).mint
796
+
797
+ conn = Faraday.new(url: 'https://api.appstoreconnect.apple.com') do |f|
798
+ f.adapter Faraday.default_adapter
799
+ end
800
+ resp = conn.get('/v1/apps') do |req|
801
+ req.params['filter[bundleId]'] = bundle_id
802
+ req.headers['Authorization'] = "Bearer #{jwt}"
803
+ end
804
+
805
+ unless resp.status.between?(200, 299)
806
+ say "✗ Apple /v1/apps lookup failed for bundle_id #{bundle_id}: #{resp.status}", :red
807
+ say " #{resp.body}", :yellow if resp.body && !resp.body.empty?
808
+ say ' Override with: mysigner ship appstore --local-only --apple-id <APPLE_APP_ID>', :yellow
809
+ exit 1
810
+ end
811
+
812
+ data = Array(JSON.parse(resp.body)['data'])
813
+ case data.length
814
+ when 0
815
+ say "✗ No App Store Connect app found for bundle_id: #{bundle_id}", :red
816
+ say " Make sure the app exists in App Store Connect under this team's account,", :yellow
817
+ say ' or override with: --apple-id <APPLE_APP_ID>', :yellow
818
+ exit 1
819
+ when 1
820
+ data.first['id'].to_s
821
+ else
822
+ ids = data.map { |a| a['id'] }.join(', ')
823
+ say "✗ Apple returned multiple apps for bundle_id #{bundle_id} (ids: #{ids}).", :red
824
+ say ' Pick one and re-run with: --apple-id <APPLE_APP_ID>', :yellow
825
+ exit 1
826
+ end
827
+ end
828
+
829
+ # mysigner-22 follow-up — local-only submit-for-review.
830
+ # Mints a fresh JWT from the same local .p8 the uploader used,
831
+ # then drives Apple's REST submission flow via AscSubmitter.
832
+ # Translates each typed error into a one-line actionable message;
833
+ # exits 1 on failure so the caller's success-path doesn't run.
834
+ def submit_appstore_local!(asc_creds:, apple_app_id:, cf_bundle_version:,
835
+ cf_bundle_short_version_string:)
836
+ require 'mysigner/auth/asc_jwt_minter'
837
+ require_relative '../upload/asc_submitter'
838
+
839
+ say '⏳ Waiting for Apple to finish processing the build...', :yellow
840
+
841
+ jwt = Mysigner::Auth::AscJwtMinter.new(
842
+ key_id: asc_creds.key_id,
843
+ issuer_id: asc_creds.issuer_id,
844
+ p8_pem: asc_creds.p8_pem
845
+ ).mint
846
+
847
+ submission_id = Mysigner::Upload::AscSubmitter.new(
848
+ jwt: jwt,
849
+ apple_app_id: apple_app_id,
850
+ cf_bundle_version: cf_bundle_version,
851
+ cf_bundle_short_version_string: cf_bundle_short_version_string
852
+ ).submit!
853
+
854
+ say "✓ Submission created (id: #{submission_id})", :green
855
+ say ' Monitor review status at: https://appstoreconnect.apple.com/apps', :cyan
856
+ say ''
857
+ rescue Mysigner::Upload::AscSubmitter::BuildProcessingTimeoutError => e
858
+ say ''
859
+ say "✗ #{e.message}", :red
860
+ say ''
861
+ say ' Tip: this is not a CLI bug — Apple is still processing your build.', :yellow
862
+ say ' Re-run `mysigner ship appstore --local-only` once it shows', :yellow
863
+ say ' "Ready to Submit" in App Store Connect, or finish manually:', :yellow
864
+ say ' https://appstoreconnect.apple.com/apps', :cyan
865
+ say ''
866
+ exit 1
867
+ rescue Mysigner::Upload::AscSubmitter::VersionAlreadyReleasedError => e
868
+ say ''
869
+ say "✗ #{e.message}", :red
870
+ say ''
871
+ exit 1
872
+ rescue Mysigner::Upload::AscSubmitter::VersionInFlightError => e
873
+ # Apple has a version for this MARKETING_VERSION that's in an
874
+ # in-flight state (in review, rejected, etc). The error message
875
+ # names the state and the next user action verbatim.
876
+ say ''
877
+ say "✗ #{e.message}", :red
878
+ say ''
879
+ say ' Monitor or act in App Store Connect:', :yellow
880
+ say ' https://appstoreconnect.apple.com/apps', :cyan
881
+ say ''
882
+ exit 1
883
+ rescue Mysigner::Upload::AscSubmitter::SubmissionRejectedError => e
884
+ # Surface Apple's verbatim error body — usually missing metadata
885
+ # (description, what's new, screenshots). We don't pretend to
886
+ # auto-populate metadata; the user owns it in App Store Connect.
887
+ say ''
888
+ say "✗ Apple rejected the submission: #{e.message}", :red
889
+ say ''
890
+ say ' Missing required metadata is the most common cause.', :yellow
891
+ say ' Finish the listing at: https://appstoreconnect.apple.com/apps', :cyan
892
+ say ''
893
+ exit 1
894
+ rescue Mysigner::Upload::AscSubmitter::AppleApiError => e
895
+ say ''
896
+ say "✗ App Store Connect API error: #{e.message}", :red
897
+ say ''
898
+ exit 1
899
+ end
900
+
707
901
  # Ship Android to Google Play
708
902
  def ship_android(track)
903
+ # mysigner-43 — when the user opts into local-only for `ship
904
+ # android`, surface the banner once so they know their SA-JSON
905
+ # won't traverse the MySigner server. The uploader itself
906
+ # enforces the contract below.
907
+ emit_local_only_banner if local_only?
908
+
709
909
  config = load_config
710
910
  client = create_client(config)
711
911
 
@@ -763,12 +963,38 @@ module Mysigner
763
963
  local_version_code = parser.version_code.to_i
764
964
  version_name = parser.version_name
765
965
 
766
- # Check highest version code from API and auto-increment if needed
767
- highest_version_code = fetch_android_highest_version_code(client, config, package_name)
966
+ # Check highest version code from API and auto-increment if needed.
967
+ # mysigner-22 follow-up local-only mode now also runs the
968
+ # pre-check via Google Play directly (no MySigner round-trip):
969
+ # mint an OAuth2 token from local SA-JSON, list bundles on the
970
+ # app, take max(versionCode). Unlike vault mode we DO NOT
971
+ # auto-bump the AAB — that's the user's project state. We just
972
+ # warn so they can bump versionCode in their Gradle file and
973
+ # re-run rather than burn 3 minutes on a doomed upload.
974
+ #
975
+ # Best-effort: if the mint fails (network, mock, expired key)
976
+ # we skip the pre-check rather than fail the ship. Google will
977
+ # still reject at upload time with a clear message.
978
+ highest_version_code =
979
+ if local_only?
980
+ fetch_local_only_highest_version_code(package_name: package_name)
981
+ else
982
+ fetch_android_highest_version_code(client, config, package_name)
983
+ end
768
984
  version_code = local_version_code
769
985
  version_code_override = nil
770
986
 
771
- if highest_version_code && local_version_code <= highest_version_code
987
+ if local_only? && highest_version_code && local_version_code <= highest_version_code
988
+ # Local-only: warn-only. Bumping the AAB's versionCode
989
+ # would silently mutate the user's project state, which
990
+ # the brief is explicit we should not do.
991
+ say ''
992
+ say "[mysigner] Your project's versionCode (#{local_version_code}) is ≤ Google Play's latest (#{highest_version_code}).", :red
993
+ say "Google will reject this upload. Bump versionCode to #{highest_version_code + 1} or higher in", :red
994
+ say 'android/app/build.gradle and re-run.', :red
995
+ say ''
996
+ exit 1
997
+ elsif highest_version_code && local_version_code <= highest_version_code
772
998
  version_code = highest_version_code + 1
773
999
  version_code_override = version_code
774
1000
  say "📦 Package: #{package_name}", :cyan
@@ -800,53 +1026,85 @@ module Mysigner
800
1026
  say '=' * 80, :cyan
801
1027
  say ''
802
1028
 
803
- # Fetch keystore from API (prefer app-specific, fallback to org-wide)
804
- say '🔐 Fetching keystore from My Signer...', :yellow
805
-
806
- require_relative '../signing/keystore_manager'
807
- keystore_manager = Signing::KeystoreManager.new(client, config.current_organization_id)
808
-
809
- # Try to find the app to get app-specific + org-wide keystores
810
- app_id = nil
811
- begin
812
- response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
813
- apps = response[:data]['android_apps'] || []
814
- app = apps.find { |a| a['package_name'] == package_name }
815
- app_id = app['id'] if app
816
- rescue StandardError
817
- # Continue without app ID
818
- end
819
-
820
- active_keystore = keystore_manager.active_keystore(android_app_id: app_id, include_secrets: true)
821
- unless active_keystore
1029
+ # mysigner-22 Phase 7 local-only mode resolves the keystore
1030
+ # via the credential cascade (flag env → keychain → project
1031
+ # sniff → prompt). The MySigner server is never contacted for
1032
+ # the .jks blob, passwords, or alias. In vault mode the old
1033
+ # KeystoreManager path runs unchanged.
1034
+ active_keystore = nil
1035
+ keystore_path = nil
1036
+ keystore_password = nil
1037
+ key_password = nil
1038
+ key_alias = nil
1039
+
1040
+ if local_only?
1041
+ say '🔐 Resolving Android keystore locally (--local-only)...', :yellow
1042
+ android_creds = resolve_local_android_keystore_or_exit
1043
+ keystore_path = android_creds.keystore_path
1044
+ keystore_password = android_creds.keystore_password
1045
+ key_password = android_creds.key_password || keystore_password
1046
+ key_alias = android_creds.key_alias
1047
+ # Hold the tmpfile (if any) on a local so GC can't unlink
1048
+ # the materialized .jks mid-build. The local goes out of
1049
+ # scope when ship_android returns, which is fine — the
1050
+ # build/upload have both consumed the file by then.
1051
+ _keystore_tmpfile_hold = android_creds.tmpfile
1052
+ say "✓ Keystore ready at: #{keystore_path} (source: #{android_creds.source})", :green
822
1053
  say ''
823
- say '✗ No active keystore found', :red
824
- say ''
825
- say 'Quick fix:', :cyan
826
- say ' 1. Upload a keystore: mysigner keystore upload', :green
827
- say ' 2. Or configure in My Signer dashboard', :green
828
- say ''
829
- exit 1
830
- end
1054
+ else
1055
+ # Fetch keystore from API (prefer app-specific, fallback to org-wide)
1056
+ say '🔐 Fetching keystore from My Signer...', :yellow
831
1057
 
832
- say "✓ Using keystore: #{active_keystore['name']}", :green
1058
+ require_relative '../signing/keystore_manager'
1059
+ keystore_manager = Signing::KeystoreManager.new(client, config.current_organization_id)
833
1060
 
834
- # Download keystore
835
- keystore_info = keystore_manager.get_or_download(active_keystore['id'])
836
- say "✓ Keystore ready at: #{keystore_info[:path]}", :green
837
- say ''
1061
+ # Try to find the app to get app-specific + org-wide keystores
1062
+ app_id = nil
1063
+ begin
1064
+ response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
1065
+ apps = response[:data]['android_apps'] || []
1066
+ app = apps.find { |a| a['package_name'] == package_name }
1067
+ app_id = app['id'] if app
1068
+ rescue StandardError
1069
+ # Continue without app ID
1070
+ end
1071
+
1072
+ active_keystore = keystore_manager.active_keystore(android_app_id: app_id)
1073
+ unless active_keystore
1074
+ say ''
1075
+ say '✗ No active keystore found', :red
1076
+ say ''
1077
+ say 'Quick fix:', :cyan
1078
+ say ' 1. Upload a keystore: mysigner keystore upload', :green
1079
+ say ' 2. Or configure in My Signer dashboard', :green
1080
+ say ''
1081
+ exit 1
1082
+ end
838
1083
 
839
- # Get keystore credentials from API response
840
- keystore_password = active_keystore['keystore_password'] || ENV.fetch('MYSIGNER_KEYSTORE_PASSWORD', nil)
841
- key_password = active_keystore['key_password'] || ENV['MYSIGNER_KEY_PASSWORD'] || keystore_password
842
- key_alias = active_keystore['key_alias']
1084
+ say "✓ Using keystore: #{active_keystore['name']}", :green
843
1085
 
844
- unless keystore_password
845
- say '⚠️ Keystore password not found in My Signer', :yellow
846
- say ' Upload your keystore with password: mysigner keystore upload FILE', :yellow
847
- keystore_password = ask('Keystore password:', echo: false)
1086
+ # Download keystore
1087
+ keystore_info = keystore_manager.get_or_download(active_keystore['id'])
1088
+ keystore_path = keystore_info[:path]
1089
+ say "✓ Keystore ready at: #{keystore_path}", :green
848
1090
  say ''
849
- key_password ||= keystore_password
1091
+
1092
+ # mysigner-49: passwords are never returned inline on the
1093
+ # active_keystore (list) payload. Fetch them through the
1094
+ # dedicated, audit-logged /secrets endpoint instead. ENV
1095
+ # vars remain a manual override for power users.
1096
+ secrets = keystore_manager.fetch_secrets(active_keystore['id'])
1097
+ keystore_password = secrets['keystore_password'] || ENV.fetch('MYSIGNER_KEYSTORE_PASSWORD', nil)
1098
+ key_password = secrets['key_password'] || ENV['MYSIGNER_KEY_PASSWORD'] || keystore_password
1099
+ key_alias = secrets['key_alias'] || active_keystore['key_alias']
1100
+
1101
+ unless keystore_password
1102
+ say '⚠️ Keystore password not found in My Signer', :yellow
1103
+ say ' Upload your keystore with password: mysigner keystore upload FILE', :yellow
1104
+ keystore_password = ask('Keystore password:', echo: false)
1105
+ say ''
1106
+ key_password ||= keystore_password
1107
+ end
850
1108
  end
851
1109
 
852
1110
  # Build AAB
@@ -855,7 +1113,7 @@ module Mysigner
855
1113
 
856
1114
  aab_path = executor.build_aab!(
857
1115
  variant: 'release',
858
- keystore_path: keystore_info[:path],
1116
+ keystore_path: keystore_path,
859
1117
  keystore_password: keystore_password,
860
1118
  key_alias: key_alias,
861
1119
  key_password: key_password,
@@ -879,35 +1137,53 @@ module Mysigner
879
1137
 
880
1138
  # Phase 0: mint short-lived OAuth2 access token server-side.
881
1139
  # Service-account JSON never leaves the server.
882
- say '🔐 Requesting Google Play access token...', :yellow
1140
+ #
1141
+ # mysigner-43: in local-only mode the token is minted *inside*
1142
+ # PlayStoreUploader from Keychain-backed SA-JSON. Skip the
1143
+ # server round-trip entirely.
1144
+ access_token = nil
1145
+ if local_only?
1146
+ say '🔐 Local-only mode — will mint Google Play access token locally.', :yellow
1147
+ else
1148
+ say '🔐 Requesting Google Play access token...', :yellow
883
1149
 
884
- begin
885
- token_response = client.post(
886
- "/api/v1/organizations/#{config.current_organization_id}/credentials/google_play/access_token"
887
- )
888
- access_token = token_response[:data]['access_token']
889
- rescue Mysigner::NotFoundError, Mysigner::ValidationError
890
- say ''
891
- say '✗ Google Play credentials not configured', :red
892
- say ''
893
- say 'Quick fix:', :cyan
894
- say ' Configure Google Play credentials in My Signer dashboard', :green
895
- say ''
896
- exit 1
897
- end
1150
+ begin
1151
+ token_response = client.post(
1152
+ "/api/v1/organizations/#{config.current_organization_id}/credentials/google_play/access_token"
1153
+ )
1154
+ access_token = token_response[:data]['access_token']
1155
+ rescue Mysigner::NotFoundError, Mysigner::ValidationError
1156
+ say ''
1157
+ say '✗ Google Play credentials not configured', :red
1158
+ say ''
1159
+ say 'Quick fix:', :cyan
1160
+ say ' Configure Google Play credentials in My Signer dashboard', :green
1161
+ say ''
1162
+ exit 1
1163
+ end
898
1164
 
899
- if access_token.nil? || access_token.to_s.empty?
900
- say '✗ Error: Failed to mint Google Play access token', :red
901
- exit 1
902
- end
1165
+ if access_token.nil? || access_token.to_s.empty?
1166
+ say '✗ Error: Failed to mint Google Play access token', :red
1167
+ exit 1
1168
+ end
903
1169
 
904
- say '✓ Access token minted (valid ~1 hour)', :green
1170
+ say '✓ Access token minted (valid ~1 hour)', :green
1171
+ end
905
1172
  say ''
906
1173
 
907
1174
  # Phase 0: fetch Android CLI Defaults from the dashboard
908
1175
  # (android_apps.cli_defaults JSONB). Fields here act as base
909
1176
  # values; explicit CLI flags below layer on top.
910
- android_defaults = fetch_android_release_defaults(client, config, package_name)
1177
+ # mysigner-22 Phase 7 — local-only mode bypasses dashboard
1178
+ # defaults; the user supplies everything via flags or accepts
1179
+ # the built-in defaults below.
1180
+ android_defaults =
1181
+ if local_only?
1182
+ warn '[mysigner] local-only: skipping fetch_android_release_defaults (server-only feature; using CLI flags + defaults)'
1183
+ nil
1184
+ else
1185
+ fetch_android_release_defaults(client, config, package_name)
1186
+ end
911
1187
  if android_defaults
912
1188
  say "✓ Loaded CLI Defaults for #{package_name}", :green
913
1189
  say ''
@@ -938,10 +1214,18 @@ module Mysigner
938
1214
  changes_not_sent_for_review = android_defaults&.key?('changes_not_sent_for_review') ? android_defaults['changes_not_sent_for_review'] : nil
939
1215
  country_targeting = country_targeting.transform_keys(&:to_sym) if country_targeting.is_a?(Hash)
940
1216
 
1217
+ # mysigner-22 Phase 5 — pre-resolve Play creds via the
1218
+ # cascade (flag → env → keychain → project-sniff → prompt).
1219
+ # In vault mode this is nil and the existing access_token
1220
+ # round-trip is unchanged.
1221
+ play_creds_for_uploader = (resolve_local_play_creds_or_exit if local_only?)
1222
+
941
1223
  uploader = Upload::PlayStoreUploader.new(
942
1224
  aab_path: aab_path,
943
1225
  access_token: access_token,
944
- package_name: package_name
1226
+ package_name: package_name,
1227
+ local_only: local_only?,
1228
+ play_creds: play_creds_for_uploader
945
1229
  )
946
1230
 
947
1231
  uploader.upload!(
@@ -958,8 +1242,15 @@ module Mysigner
958
1242
  timings[:upload] = Time.now - upload_start
959
1243
  timings[:total] = Time.now - overall_start
960
1244
 
961
- # Link keystore to app in MySigner (so dashboard shows it)
962
- if active_keystore && active_keystore['id']
1245
+ # Link keystore to app in MySigner (so dashboard shows it).
1246
+ # mysigner-22 Phase 7 — local-only mode never has an
1247
+ # active_keystore record (the .jks came from the user's
1248
+ # machine, not MySigner), so the link step is unconditionally
1249
+ # skipped here. Surface it so users know the dashboard won't
1250
+ # auto-update.
1251
+ if local_only?
1252
+ warn '[mysigner] local-only: skipping android_keystores/:id/link_to_app (server-only feature)'
1253
+ elsif active_keystore && active_keystore['id']
963
1254
  begin
964
1255
  client.post(
965
1256
  "/api/v1/organizations/#{config.current_organization_id}/android_keystores/#{active_keystore['id']}/link_to_app",
@@ -970,8 +1261,14 @@ module Mysigner
970
1261
  end
971
1262
  end
972
1263
 
973
- # Save build record to MySigner (for version tracking)
974
- save_android_build_record(client, config, package_name, version_code, version_name)
1264
+ # Save build record to MySigner (for version tracking).
1265
+ # mysigner-22 Phase 7 — local-only mode has no MySigner
1266
+ # record to update; skip and warn.
1267
+ if local_only?
1268
+ warn '[mysigner] local-only: skipping save_android_build_record (server-only feature)'
1269
+ else
1270
+ save_android_build_record(client, config, package_name, version_code, version_name)
1271
+ end
975
1272
 
976
1273
  # SUCCESS SUMMARY
977
1274
  say ''
@@ -1033,6 +1330,14 @@ module Mysigner
1033
1330
  say ' → For Flutter: check android/app/build.gradle exists', :yellow
1034
1331
  say ''
1035
1332
  exit 1
1333
+ rescue Upload::PlayStoreUploader::MissingLocalCredentialsError => e
1334
+ # mysigner-43 — local-only requested but no credentials stored.
1335
+ # Fail loud with a non-stack-trace message; the message already
1336
+ # tells the user where to store the credentials.
1337
+ say ''
1338
+ say "✗ #{e.message}", :red
1339
+ say ''
1340
+ exit 1
1036
1341
  rescue Upload::PlayStoreUploader::PartialUploadError => e
1037
1342
  # AAB was uploaded but track assignment/commit failed
1038
1343
  # Save build record to prevent version conflicts on retry
@@ -1044,11 +1349,14 @@ module Mysigner
1044
1349
  say "Error: #{e.message}", :red
1045
1350
  say ''
1046
1351
 
1047
- # Save build record even on partial failure (AAB is on Play Store)
1048
- if e.version_code
1352
+ # Save build record even on partial failure (AAB is on Play Store).
1353
+ # mysigner-22 Phase 7 — skip the server write in local-only mode.
1354
+ if e.version_code && !local_only?
1049
1355
  save_android_build_record(client, config, package_name, e.version_code, version_name)
1050
1356
  say "📝 Build v#{e.version_code} recorded (prevents version conflicts on retry)", :yellow
1051
1357
  say ''
1358
+ elsif e.version_code && local_only?
1359
+ warn '[mysigner] local-only: skipping save_android_build_record on partial-upload retry path'
1052
1360
  end
1053
1361
 
1054
1362
  # Show track setup suggestions
@@ -1116,6 +1424,28 @@ module Mysigner
1116
1424
  nil
1117
1425
  end
1118
1426
 
1427
+ # mysigner-22 follow-up — local-only equivalent of
1428
+ # fetch_android_highest_version_code. Resolves Play creds via the
1429
+ # cascade, mints an OAuth2 token, and asks Google Play directly.
1430
+ # Best-effort: any failure (mint error, network, list error)
1431
+ # returns nil so the ship proceeds — Google will still reject at
1432
+ # upload time with a clear message, but a transient pre-check
1433
+ # failure shouldn't block the user.
1434
+ def fetch_local_only_highest_version_code(package_name:)
1435
+ require 'mysigner/auth/google_oauth_minter'
1436
+
1437
+ play_creds = resolve_local_play_creds_or_exit
1438
+ token = Mysigner::Auth::GoogleOauthMinter.new(play_creds.sa_json)
1439
+ .mint(scope: Upload::PlayStoreUploader::SCOPE)
1440
+ Upload::PlayStoreUploader.fetch_highest_version_code(
1441
+ package_name: package_name,
1442
+ access_token: token
1443
+ )
1444
+ rescue StandardError => e
1445
+ warn "[mysigner] local-only: skipping versionCode pre-check (#{e.class}: #{e.message})"
1446
+ nil
1447
+ end
1448
+
1119
1449
  # Fetch highest version code from API
1120
1450
  def fetch_android_highest_version_code(client, config, package_name)
1121
1451
  response = client.get("/api/v1/organizations/#{config.current_organization_id}/android_apps")
@@ -1631,7 +1961,8 @@ module Mysigner
1631
1961
 
1632
1962
  # Pre-build validation
1633
1963
  say '🔍 Validating signing setup...', :cyan
1634
- validator = Signing::Validator.new(parser, target_name, options[:configuration], team_id: team_id_to_use)
1964
+ validator = Signing::Validator.new(parser, target_name, options[:configuration],
1965
+ team_id: team_id_to_use, local_only: local_only?)
1635
1966
  validator.validate!
1636
1967
 
1637
1968
  # Build
@@ -1774,101 +2105,51 @@ module Mysigner
1774
2105
  exit 1
1775
2106
  end
1776
2107
 
1777
- if ENV['MYSIGNER_USE_LEGACY_ASC'] == '1'
1778
- # LEGACY PATH — altool with server-fetched .p8. Will be removed in next release.
1779
- say '⚠️ MYSIGNER_USE_LEGACY_ASC=1 — using legacy altool upload path (deprecated).', :yellow
1780
- say '🔐 Fetching App Store Connect credentials...', :yellow
2108
+ # ASC REST Build Upload API.
2109
+ require_relative '../upload/asc_rest_uploader'
1781
2110
 
1782
- begin
1783
- org_response = client.get("/api/v1/organizations/#{config.current_organization_id}")
1784
- org_data = org_response[:data]
2111
+ ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
2112
+ bundle_id = ipa_info[:bundle_id]
2113
+ cf_version = ipa_info[:cf_bundle_version] || '1'
2114
+ cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
1785
2115
 
1786
- unless org_data['app_store_connect_configured']
1787
- say ''
1788
- say ' App Store Connect credentials not configured', :red
1789
- say ''
1790
- say 'Quick fix:', :cyan
1791
- say ' mysigner doctor # Auto-configure now', :green
1792
- say ''
1793
- say 'Or manually:', :cyan
1794
- say ' 1. Run: mysigner onboard'
1795
- say ' 2. Follow Step 5 to add credentials'
1796
- say ''
1797
- exit 1
1798
- end
1799
-
1800
- api_key = org_data['app_store_connect_key_id']
1801
- api_issuer = org_data['app_store_connect_issuer_id']
1802
- private_key = org_data['app_store_connect_private_key']
1803
-
1804
- unless api_key && api_issuer && private_key
1805
- say '✗ Error: Invalid credentials received from API', :red
1806
- exit 1
1807
- end
1808
-
1809
- say '✓ Credentials loaded', :green
1810
- say ''
1811
- rescue Mysigner::ClientError => e
1812
- say ''
1813
- say "✗ Error fetching credentials: #{e.message}", :red
1814
- exit 1
1815
- end
1816
-
1817
- uploader = Upload::Uploader.new(
1818
- ipa_path,
1819
- api_key: api_key,
1820
- api_issuer: api_issuer,
1821
- private_key: private_key
1822
- )
1823
-
1824
- uploader.upload!(wait_for_processing: options[:wait])
1825
- else
1826
- # DEFAULT PATH — ASC REST Build Upload API.
1827
- require_relative '../upload/asc_rest_uploader'
1828
-
1829
- ipa_info = Upload::Uploader.extract_ipa_info(ipa_path)
1830
- bundle_id = ipa_info[:bundle_id]
1831
- cf_version = ipa_info[:cf_bundle_version] || '1'
1832
- cf_short = ipa_info[:cf_bundle_short_version_string] || '1.0'
1833
-
1834
- if bundle_id.nil? || bundle_id.empty?
1835
- say '✗ Could not extract bundle identifier from IPA.', :red
1836
- say ' Ensure the file is a valid iOS .ipa with a Payload/*.app/Info.plist.', :yellow
1837
- exit 1
1838
- end
2116
+ if bundle_id.nil? || bundle_id.empty?
2117
+ say '✗ Could not extract bundle identifier from IPA.', :red
2118
+ say ' Ensure the file is a valid iOS .ipa with a Payload/*.app/Info.plist.', :yellow
2119
+ exit 1
2120
+ end
1839
2121
 
1840
- app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
1841
- params: { bundle_id: bundle_id })
1842
- app = Array(app_response.dig(:data, 'data', 'apps')).first
2122
+ app_response = client.get("/api/v1/organizations/#{config.current_organization_id}/apple_apps",
2123
+ params: { bundle_id: bundle_id })
2124
+ app = Array(app_response.dig(:data, 'data', 'apps')).first
1843
2125
 
1844
- unless app && app['id']
1845
- say "✗ App with bundle ID '#{bundle_id}' not found in MySigner.", :red
1846
- say ' Run: mysigner sync ios', :yellow
1847
- exit 1
1848
- end
2126
+ unless app && app['id']
2127
+ say "✗ App with bundle ID '#{bundle_id}' not found in MySigner.", :red
2128
+ say ' Run: mysigner sync ios', :yellow
2129
+ exit 1
2130
+ end
1849
2131
 
1850
- say "📤 Uploading #{File.basename(ipa_path)} via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
2132
+ say "📤 Uploading #{File.basename(ipa_path)} via App Store Connect REST API (version #{cf_short} build #{cf_version})...", :cyan
1851
2133
 
1852
- rest = Mysigner::Upload::AscRestUploader.new(
1853
- client: client,
1854
- organization_id: config.current_organization_id,
1855
- ipa_path: ipa_path,
1856
- apple_app_id: app['id'],
1857
- cf_bundle_version: cf_version,
1858
- cf_bundle_short_version_string: cf_short,
1859
- platform: 'IOS'
1860
- )
2134
+ rest = Mysigner::Upload::AscRestUploader.new(
2135
+ client: client,
2136
+ organization_id: config.current_organization_id,
2137
+ ipa_path: ipa_path,
2138
+ apple_app_id: app['id'],
2139
+ cf_bundle_version: cf_version,
2140
+ cf_bundle_short_version_string: cf_short,
2141
+ platform: 'IOS'
2142
+ )
1861
2143
 
1862
- result = rest.call
1863
- case result[:final_state]
1864
- when 'COMPLETE'
1865
- say '✓ Upload complete — Apple accepted the build', :green
1866
- when 'FAILED', 'INVALIDATED'
1867
- say "✗ Apple rejected the upload: #{result[:final_state]}", :red
1868
- exit 1
1869
- when 'TIMEOUT'
1870
- say '⚠ Apple is still processing — check App Store Connect.', :yellow
1871
- end
2144
+ result = rest.call
2145
+ case result[:final_state]
2146
+ when 'COMPLETE'
2147
+ say '✓ Upload complete — Apple accepted the build', :green
2148
+ when 'FAILED', 'INVALIDATED'
2149
+ say "✗ Apple rejected the upload: #{result[:final_state]}", :red
2150
+ exit 1
2151
+ when 'TIMEOUT'
2152
+ say '⚠ Apple is still processing — check App Store Connect.', :yellow
1872
2153
  end
1873
2154
 
1874
2155
  say '🎉 Upload complete!', :green
@@ -1878,14 +2159,6 @@ module Mysigner
1878
2159
  say ' • Wait for processing (5-15 minutes)'
1879
2160
  say ' • Distribute to TestFlight testers'
1880
2161
  say ''
1881
- rescue Upload::Uploader::TransporterNotFoundError => e
1882
- say ''
1883
- say "✗ Error: #{e.message}", :red
1884
- exit 1
1885
- rescue Upload::Uploader::UploadError => e
1886
- say ''
1887
- say "✗ Upload Error: #{e.message}", :red
1888
- exit 1
1889
2162
  rescue Mysigner::Upload::AscRestUploader::BuildVersionConflictError => e
1890
2163
  say ''
1891
2164
  say "✗ #{e.message}", :red