mysigner 0.1.3 → 0.1.4
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 +4 -0
- data/.rubocop_todo.yml +23 -9
- data/Gemfile.lock +1 -1
- data/README.md +1 -1
- data/certificate_.cer +0 -0
- data/exe/mysigner +17 -1
- data/iOS_App_Store_Profile.mobileprovision +1 -0
- data/iOS_Distribution_Certificate.cer +1 -0
- data/lib/mysigner/build/android_executor.rb +37 -11
- data/lib/mysigner/cleanup/private_keys_purger.rb +41 -0
- data/lib/mysigner/cli/auth_commands.rb +42 -18
- data/lib/mysigner/cli/build_commands.rb +307 -117
- data/lib/mysigner/cli/concerns/helpers.rb +32 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +8 -1
- data/lib/mysigner/cli/resource_commands.rb +304 -114
- data/lib/mysigner/cli.rb +8 -0
- data/lib/mysigner/config.rb +68 -4
- data/lib/mysigner/export/exporter.rb +6 -1
- data/lib/mysigner/signing/gradle_signing_injector.rb +67 -0
- data/lib/mysigner/signing/keystore_manager.rb +50 -25
- data/lib/mysigner/upload/app_store_automation.rb +46 -1
- data/lib/mysigner/upload/asc_rest_uploader.rb +119 -0
- data/lib/mysigner/upload/play_store_uploader.rb +77 -40
- data/lib/mysigner/upload/uploader.rb +41 -12
- data/lib/mysigner/version.rb +1 -1
- data/profile_.mobileprovision +0 -0
- metadata +8 -2
- data/.DS_Store +0 -0
data/lib/mysigner/config.rb
CHANGED
|
@@ -8,11 +8,13 @@ require 'openssl'
|
|
|
8
8
|
require 'base64'
|
|
9
9
|
require 'json'
|
|
10
10
|
require 'securerandom'
|
|
11
|
+
require 'rbconfig'
|
|
11
12
|
|
|
12
13
|
module Mysigner
|
|
13
14
|
class Config
|
|
14
15
|
CONFIG_DIR = File.expand_path('~/.mysigner').freeze
|
|
15
16
|
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml').freeze
|
|
17
|
+
KEY_FILE = File.join(CONFIG_DIR, '.encryption_key').freeze
|
|
16
18
|
KEYCHAIN_SERVICE = 'com.mysigner.cli'
|
|
17
19
|
KEYCHAIN_ACCOUNT = 'config_encryption_key'
|
|
18
20
|
|
|
@@ -160,6 +162,17 @@ module Mysigner
|
|
|
160
162
|
@organizations = {}
|
|
161
163
|
|
|
162
164
|
File.delete(CONFIG_FILE) if exists?
|
|
165
|
+
|
|
166
|
+
# On non-macOS the encryption key lives in a file fallback. Wipe it on
|
|
167
|
+
# logout so a fresh login can mint a new key — otherwise the old key
|
|
168
|
+
# would silently encrypt a new token that nobody else can decrypt.
|
|
169
|
+
FileUtils.rm_f(KEY_FILE)
|
|
170
|
+
|
|
171
|
+
# Phase 0: logout also purges the keystore cache so a shared machine
|
|
172
|
+
# doesn't leave prior-user keystore blobs on disk.
|
|
173
|
+
keystores_dir = File.expand_path('~/.mysigner/keystores')
|
|
174
|
+
FileUtils.rm_rf(keystores_dir)
|
|
175
|
+
|
|
163
176
|
true
|
|
164
177
|
rescue StandardError => e
|
|
165
178
|
raise ConfigError, "Failed to clear config: #{e.message}"
|
|
@@ -304,16 +317,34 @@ module Mysigner
|
|
|
304
317
|
end
|
|
305
318
|
|
|
306
319
|
def get_or_create_encryption_key
|
|
307
|
-
#
|
|
308
|
-
|
|
320
|
+
# macOS Keychain is the preferred key store. On Linux/Windows we fall
|
|
321
|
+
# back to a 0600 file in the config dir so the encrypted YAML token
|
|
322
|
+
# is still recoverable across CLI invocations. The fallback is roughly
|
|
323
|
+
# equivalent in security to the config file itself; for the strongest
|
|
324
|
+
# posture in CI, prefer the MYSIGNER_API_TOKEN env var path.
|
|
325
|
+
if macos_keychain?
|
|
326
|
+
key = get_key_from_keychain
|
|
327
|
+
return key if key
|
|
328
|
+
|
|
329
|
+
new_key = SecureRandom.bytes(32) # 256-bit key
|
|
330
|
+
store_key_in_keychain(new_key)
|
|
331
|
+
return new_key
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
key = read_key_from_file
|
|
309
335
|
return key if key
|
|
310
336
|
|
|
311
|
-
|
|
337
|
+
warn_keychain_unavailable_once
|
|
338
|
+
ensure_config_dir_exists
|
|
312
339
|
new_key = SecureRandom.bytes(32) # 256-bit key
|
|
313
|
-
|
|
340
|
+
write_key_to_file(new_key)
|
|
314
341
|
new_key
|
|
315
342
|
end
|
|
316
343
|
|
|
344
|
+
def macos_keychain?
|
|
345
|
+
RbConfig::CONFIG['host_os'] =~ /darwin/i
|
|
346
|
+
end
|
|
347
|
+
|
|
317
348
|
def get_key_from_keychain
|
|
318
349
|
# Use macOS security command to get key from keychain
|
|
319
350
|
cmd = "security find-generic-password -s '#{KEYCHAIN_SERVICE}' -a '#{KEYCHAIN_ACCOUNT}' -w 2>/dev/null"
|
|
@@ -342,6 +373,39 @@ module Mysigner
|
|
|
342
373
|
rescue StandardError => e
|
|
343
374
|
raise ConfigError, "Failed to store encryption key in keychain: #{e.message}"
|
|
344
375
|
end
|
|
376
|
+
|
|
377
|
+
def read_key_from_file
|
|
378
|
+
return nil unless File.exist?(KEY_FILE)
|
|
379
|
+
|
|
380
|
+
encoded = File.read(KEY_FILE).strip
|
|
381
|
+
return nil if encoded.empty?
|
|
382
|
+
|
|
383
|
+
Base64.strict_decode64(encoded)
|
|
384
|
+
rescue StandardError
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def write_key_to_file(key)
|
|
389
|
+
File.write(KEY_FILE, Base64.strict_encode64(key))
|
|
390
|
+
File.chmod(0o600, KEY_FILE)
|
|
391
|
+
true
|
|
392
|
+
rescue StandardError => e
|
|
393
|
+
raise ConfigError, "Failed to write encryption key file: #{e.message}"
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def warn_keychain_unavailable_once
|
|
397
|
+
return if defined?(@keychain_warning_shown) && @keychain_warning_shown
|
|
398
|
+
|
|
399
|
+
@keychain_warning_shown = true
|
|
400
|
+
return unless $stderr.respond_to?(:tty?) && $stderr.tty?
|
|
401
|
+
|
|
402
|
+
warn(<<~MSG)
|
|
403
|
+
[mysigner] macOS Keychain is unavailable on this platform. Falling
|
|
404
|
+
back to file-based encryption key at #{KEY_FILE} (mode 0600).
|
|
405
|
+
For the strongest CI/CD posture, set MYSIGNER_API_TOKEN as an
|
|
406
|
+
encrypted secret instead — env-var auth never touches the disk.
|
|
407
|
+
MSG
|
|
408
|
+
end
|
|
345
409
|
end
|
|
346
410
|
|
|
347
411
|
class ConfigError < StandardError; end
|
|
@@ -108,9 +108,14 @@ module Mysigner
|
|
|
108
108
|
'-allowProvisioningUpdates' # Allow Xcode to update profiles if needed
|
|
109
109
|
].join(' ')
|
|
110
110
|
|
|
111
|
-
# Run command and capture output
|
|
111
|
+
# Run command and capture output. xcodebuild output can contain
|
|
112
|
+
# non-ASCII bytes (smart quotes in Apple error messages, emoji in
|
|
113
|
+
# file paths) — force UTF-8 + scrub so `.strip` / `.include?` don't
|
|
114
|
+
# raise Encoding::CompatibilityError under the default US-ASCII
|
|
115
|
+
# locale (e.g. on CI runners without LANG set).
|
|
112
116
|
IO.popen(cmd, err: %i[child out]) do |io|
|
|
113
117
|
io.each_line do |line|
|
|
118
|
+
line = line.force_encoding('UTF-8').scrub
|
|
114
119
|
next if line.strip.empty?
|
|
115
120
|
|
|
116
121
|
# Show errors and warnings
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
|
|
6
|
+
module Mysigner
|
|
7
|
+
module Signing
|
|
8
|
+
class GradleSigningInjector
|
|
9
|
+
# Local variable is `aliasName` (not `keyAlias`) to avoid a Groovy
|
|
10
|
+
# `with { }` scoping ambiguity: `keyAlias = keyAlias` would resolve the
|
|
11
|
+
# RHS against the property being set (null at that point), silently
|
|
12
|
+
# signing with the wrong alias.
|
|
13
|
+
INIT_SCRIPT = <<~GROOVY
|
|
14
|
+
allprojects {
|
|
15
|
+
afterEvaluate { project ->
|
|
16
|
+
if (!project.hasProperty('android')) return
|
|
17
|
+
def storePw = System.getenv('MYSIGNER_STORE_PASSWORD')
|
|
18
|
+
def keyPw = System.getenv('MYSIGNER_KEY_PASSWORD')
|
|
19
|
+
def aliasName = System.getenv('MYSIGNER_KEY_ALIAS')
|
|
20
|
+
def ksPath = System.getenv('MYSIGNER_STORE_FILE')
|
|
21
|
+
if (!storePw || !ksPath) return
|
|
22
|
+
|
|
23
|
+
def existing = project.android.signingConfigs.findByName('release')
|
|
24
|
+
def alreadyConfigured = existing != null && existing.storeFile != null
|
|
25
|
+
if (alreadyConfigured) {
|
|
26
|
+
println "MySigner: release signingConfig already set; skipping override."
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
project.android.signingConfigs.maybeCreate('release').with {
|
|
30
|
+
storeFile = file(ksPath)
|
|
31
|
+
storePassword = storePw
|
|
32
|
+
keyAlias = aliasName
|
|
33
|
+
keyPassword = keyPw
|
|
34
|
+
}
|
|
35
|
+
project.android.buildTypes.findByName('release')?.signingConfig =
|
|
36
|
+
project.android.signingConfigs.getByName('release')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
GROOVY
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@tmpdir = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_init_script!
|
|
46
|
+
@tmpdir = Dir.mktmpdir('mysigner-signing-')
|
|
47
|
+
path = File.join(@tmpdir, 'init.gradle')
|
|
48
|
+
File.write(path, INIT_SCRIPT)
|
|
49
|
+
path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def env_vars(keystore_path:, store_password:, key_password:, key_alias:)
|
|
53
|
+
{
|
|
54
|
+
'MYSIGNER_STORE_FILE' => keystore_path,
|
|
55
|
+
'MYSIGNER_STORE_PASSWORD' => store_password,
|
|
56
|
+
'MYSIGNER_KEY_PASSWORD' => key_password,
|
|
57
|
+
'MYSIGNER_KEY_ALIAS' => key_alias
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def cleanup!
|
|
62
|
+
FileUtils.rm_rf(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
|
|
63
|
+
@tmpdir = nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'English'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'base64'
|
|
6
|
+
require 'open3'
|
|
6
7
|
|
|
7
8
|
module Mysigner
|
|
8
9
|
module Signing
|
|
@@ -21,11 +22,13 @@ module Mysigner
|
|
|
21
22
|
|
|
22
23
|
# List all keystores from API
|
|
23
24
|
# @param android_app_id [Integer, nil] Filter by app ID
|
|
24
|
-
# @param include_secrets [Boolean]
|
|
25
|
-
|
|
25
|
+
# @param include_secrets [Boolean] DEPRECATED and silently ignored. Kept
|
|
26
|
+
# for signature-compat during the 10.0 transition; will be removed in
|
|
27
|
+
# the next release. Passwords are now fetched via #fetch_secrets.
|
|
28
|
+
def list(android_app_id: nil, include_secrets: nil)
|
|
29
|
+
_ = include_secrets # intentionally unused — see note above
|
|
26
30
|
params = {}
|
|
27
31
|
params[:android_app_id] = android_app_id if android_app_id
|
|
28
|
-
params[:include_secrets] = true if include_secrets
|
|
29
32
|
|
|
30
33
|
response = @client.get(
|
|
31
34
|
"/api/v1/organizations/#{@organization_id}/android_keystores",
|
|
@@ -35,12 +38,24 @@ module Mysigner
|
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# Get active keystore for an app (or any active keystore if no app specified)
|
|
38
|
-
# @param include_secrets [Boolean]
|
|
39
|
-
def active_keystore(android_app_id: nil, include_secrets:
|
|
40
|
-
|
|
41
|
+
# @param include_secrets [Boolean] DEPRECATED — see #list.
|
|
42
|
+
def active_keystore(android_app_id: nil, include_secrets: nil)
|
|
43
|
+
_ = include_secrets
|
|
44
|
+
keystores = list(android_app_id: android_app_id)
|
|
41
45
|
keystores.find { |k| k['active'] }
|
|
42
46
|
end
|
|
43
47
|
|
|
48
|
+
# Phase 0: narrow audit-logged endpoint that returns the keystore
|
|
49
|
+
# password + key password + key alias for a single keystore. Replaces
|
|
50
|
+
# the insecure ?include_secrets=true list flag.
|
|
51
|
+
# Returns a hash: { 'keystore_password' =>, 'key_password' =>, 'key_alias' => }
|
|
52
|
+
def fetch_secrets(keystore_id)
|
|
53
|
+
response = @client.post(
|
|
54
|
+
"/api/v1/organizations/#{@organization_id}/android_keystores/#{keystore_id}/secrets"
|
|
55
|
+
)
|
|
56
|
+
response[:data] || {}
|
|
57
|
+
end
|
|
58
|
+
|
|
44
59
|
# Download a keystore from API and save locally
|
|
45
60
|
# Returns: { path: String, password: String, alias: String, key_password: String }
|
|
46
61
|
def download(keystore_id)
|
|
@@ -80,28 +95,36 @@ module Mysigner
|
|
|
80
95
|
}
|
|
81
96
|
end
|
|
82
97
|
|
|
83
|
-
# Get or download keystore (uses cached version if available)
|
|
98
|
+
# Get or download keystore (uses cached version if available and fresh).
|
|
99
|
+
# Phase 0: cache has a TTL (default 24h, override via
|
|
100
|
+
# MYSIGNER_KEYSTORE_CACHE_HOURS). Stale files are deleted + re-downloaded.
|
|
84
101
|
def get_or_download(keystore_id)
|
|
85
102
|
keystores = list
|
|
86
103
|
keystore = keystores.find { |k| k['id'].to_s == keystore_id.to_s }
|
|
87
104
|
|
|
88
105
|
raise KeystoreNotFoundError, "Keystore with ID #{keystore_id} not found" unless keystore
|
|
89
106
|
|
|
90
|
-
# Check if already cached locally
|
|
91
107
|
filename = "#{keystore['name'].gsub(/[^a-zA-Z0-9_.-]/, '_')}.jks"
|
|
92
108
|
local_path = File.join(KEYSTORES_DIR, filename)
|
|
109
|
+
max_age_hours = (ENV['MYSIGNER_KEYSTORE_CACHE_HOURS'] || 24).to_i
|
|
93
110
|
|
|
94
111
|
if File.exist?(local_path)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
age_seconds = Time.now - File.mtime(local_path)
|
|
113
|
+
if age_seconds < (max_age_hours * 3600)
|
|
114
|
+
return {
|
|
115
|
+
path: local_path,
|
|
116
|
+
name: keystore['name'],
|
|
117
|
+
key_alias: keystore['key_alias'],
|
|
118
|
+
id: keystore['id'],
|
|
119
|
+
cached: true
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
# Stale cache — delete and re-download below
|
|
123
|
+
File.delete(local_path)
|
|
124
|
+
end
|
|
102
125
|
end
|
|
103
126
|
|
|
104
|
-
# Download if not cached
|
|
127
|
+
# Download if not cached (or stale)
|
|
105
128
|
result = download(keystore_id)
|
|
106
129
|
result[:cached] = false
|
|
107
130
|
result
|
|
@@ -183,13 +206,21 @@ module Mysigner
|
|
|
183
206
|
end
|
|
184
207
|
end
|
|
185
208
|
|
|
186
|
-
# Get keystore info using keytool
|
|
209
|
+
# Get keystore info using keytool.
|
|
210
|
+
# Phase 0: passes the password via a temp env var consumed by
|
|
211
|
+
# `-storepass:env` so it's never in argv/ps output.
|
|
187
212
|
def keystore_info(keystore_path, password)
|
|
188
213
|
return nil unless File.exist?(keystore_path)
|
|
189
214
|
return nil unless system('which keytool > /dev/null 2>&1')
|
|
190
215
|
|
|
191
|
-
|
|
192
|
-
|
|
216
|
+
env = { 'MYSIGNER_KS_PW' => password.to_s }
|
|
217
|
+
output, status = Open3.capture2e(
|
|
218
|
+
env,
|
|
219
|
+
'keytool', '-list', '-v',
|
|
220
|
+
'-keystore', keystore_path,
|
|
221
|
+
'-storepass:env', 'MYSIGNER_KS_PW'
|
|
222
|
+
)
|
|
223
|
+
return nil unless status.success?
|
|
193
224
|
|
|
194
225
|
# Parse output
|
|
195
226
|
info = {}
|
|
@@ -223,12 +254,6 @@ module Mysigner
|
|
|
223
254
|
f.adapter Faraday.default_adapter
|
|
224
255
|
end
|
|
225
256
|
end
|
|
226
|
-
|
|
227
|
-
def shell_escape(str)
|
|
228
|
-
return "''" if str.nil? || str.empty?
|
|
229
|
-
|
|
230
|
-
"'#{str.gsub("'", "'\\''")}'"
|
|
231
|
-
end
|
|
232
257
|
end
|
|
233
258
|
end
|
|
234
259
|
end
|
|
@@ -13,6 +13,9 @@ module Mysigner
|
|
|
13
13
|
def initialize(client:, organization_id:, opts: {})
|
|
14
14
|
@client = client
|
|
15
15
|
@organization_id = organization_id
|
|
16
|
+
# Proper boolean coercion — `!x.nil?` evaluates to `true` for `false`,
|
|
17
|
+
# which previously caused `wait: false` / `no_submit: false` to be
|
|
18
|
+
# treated as `true`. Use `!!` to get the actual boolean semantics.
|
|
16
19
|
@wait_enabled = opts.key?(:wait) ? !opts[:wait].nil? : true
|
|
17
20
|
|
|
18
21
|
poll = opts[:poll_interval] || opts[:poll_seconds]
|
|
@@ -24,6 +27,13 @@ module Mysigner
|
|
|
24
27
|
@timeout = timeout&.positive? ? timeout : DEFAULT_WAIT_TIMEOUT
|
|
25
28
|
|
|
26
29
|
@no_submit = !opts[:no_submit].nil?
|
|
30
|
+
|
|
31
|
+
# Whether to submit by default when NEITHER cli_defaults nor CLI
|
|
32
|
+
# overrides specify `auto_submit`. `ship`/`submit` pass true (it's
|
|
33
|
+
# the user's explicit intent). Dashboard `cli_defaults.auto_submit`
|
|
34
|
+
# still wins if set — we only fall back to this when silent.
|
|
35
|
+
@default_submit = opts.key?(:default_submit) ? !opts[:default_submit].nil? : false
|
|
36
|
+
|
|
27
37
|
@now = opts[:now]
|
|
28
38
|
end
|
|
29
39
|
|
|
@@ -317,6 +327,11 @@ module Mysigner
|
|
|
317
327
|
def should_submit_with_reason(metadata, overrides)
|
|
318
328
|
return [false, nil, '--no-auto-submit flag'] if @no_submit
|
|
319
329
|
|
|
330
|
+
# Precedence: explicit CLI flag > dashboard cli_defaults > command default.
|
|
331
|
+
# The command default is set by `ship appstore` / `submit` (true),
|
|
332
|
+
# preserving backward-compat when the user runs those commands without
|
|
333
|
+
# configuring anything. Dashboard `auto_submit: false` now correctly
|
|
334
|
+
# suppresses submission (previously hard-overridden at the call site).
|
|
320
335
|
if overrides.key?('auto_submit')
|
|
321
336
|
return overrides['auto_submit'] ? [true, 'CLI override',
|
|
322
337
|
nil] : [false, nil, 'CLI override disabled auto_submit']
|
|
@@ -327,6 +342,8 @@ module Mysigner
|
|
|
327
342
|
nil] : [false, nil, 'Dashboard auto_submit disabled']
|
|
328
343
|
end
|
|
329
344
|
|
|
345
|
+
return [true, 'command default', nil] if @default_submit
|
|
346
|
+
|
|
330
347
|
[false, nil, 'No auto_submit configuration']
|
|
331
348
|
end
|
|
332
349
|
|
|
@@ -353,14 +370,42 @@ module Mysigner
|
|
|
353
370
|
"Cannot submit to Apple Store: missing required fields: #{missing_fields.join(', ')}. Please configure these in your My Signer dashboard or provide via --whats-new flag."
|
|
354
371
|
end
|
|
355
372
|
|
|
373
|
+
# Primary-locale fields (backward-compatible path): backend will
|
|
374
|
+
# upsert a single localization for the app's primary locale when
|
|
375
|
+
# these top-level keys are present.
|
|
356
376
|
payload = {
|
|
357
377
|
whats_new: merged['whats_new'],
|
|
358
378
|
keywords: merged['keywords'],
|
|
359
379
|
marketing_url: merged['marketing_url'],
|
|
360
380
|
promotional_text: merged['promotional_text'],
|
|
361
381
|
support_url: merged['support_url'],
|
|
382
|
+
description: merged['description'],
|
|
362
383
|
locale: merged['locale']
|
|
363
|
-
}.compact
|
|
384
|
+
}.compact
|
|
385
|
+
|
|
386
|
+
# Multi-locale path: if cli_defaults includes a `localizations` array,
|
|
387
|
+
# forward it so the backend can PATCH/POST each locale on ASC
|
|
388
|
+
# (appStoreVersionLocalizations) instead of only the primary.
|
|
389
|
+
if merged['localizations'].is_a?(Array) && merged['localizations'].any?
|
|
390
|
+
payload[:localizations] = merged['localizations'].map do |loc|
|
|
391
|
+
next nil unless loc.is_a?(Hash)
|
|
392
|
+
|
|
393
|
+
{
|
|
394
|
+
locale: loc['locale'] || loc[:locale],
|
|
395
|
+
whats_new: loc['whats_new'] || loc[:whats_new],
|
|
396
|
+
keywords: loc['keywords'] || loc[:keywords],
|
|
397
|
+
marketing_url: loc['marketing_url'] || loc[:marketing_url],
|
|
398
|
+
promotional_text: loc['promotional_text'] || loc[:promotional_text],
|
|
399
|
+
support_url: loc['support_url'] || loc[:support_url],
|
|
400
|
+
description: loc['description'] || loc[:description]
|
|
401
|
+
}.compact
|
|
402
|
+
end.compact
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Phased release: backend flips @version.phased_release_pending and
|
|
406
|
+
# enqueues PhasedReleaseActivationJob, which POSTs
|
|
407
|
+
# appStoreVersionPhasedReleases after Apple approves the submission.
|
|
408
|
+
payload[:phased_release] = true if merged['phased_release']
|
|
364
409
|
|
|
365
410
|
@client.post(
|
|
366
411
|
"/api/v1/organizations/#{@organization_id}/app_store_versions/#{version_id}/submit",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Mysigner
|
|
8
|
+
module Upload
|
|
9
|
+
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
|
+
class BuildVersionConflictError < StandardError; end
|
|
14
|
+
|
|
15
|
+
TERMINAL_APPLE_STATES = %w[COMPLETE FAILED INVALIDATED].freeze
|
|
16
|
+
POLL_INTERVAL = 10
|
|
17
|
+
POLL_TIMEOUT = 600
|
|
18
|
+
CHUNK_RETRIES = 2
|
|
19
|
+
|
|
20
|
+
def initialize(client:, organization_id:, ipa_path:, apple_app_id:,
|
|
21
|
+
cf_bundle_version:, cf_bundle_short_version_string:,
|
|
22
|
+
platform: 'IOS', poll_interval: POLL_INTERVAL, poll_timeout: POLL_TIMEOUT)
|
|
23
|
+
@client = client
|
|
24
|
+
@org_id = organization_id
|
|
25
|
+
@ipa_path = ipa_path
|
|
26
|
+
@apple_app_id = apple_app_id
|
|
27
|
+
@cf_bundle_version = cf_bundle_version
|
|
28
|
+
@cf_bundle_short_version_string = cf_bundle_short_version_string
|
|
29
|
+
@platform = platform
|
|
30
|
+
@poll_interval = poll_interval
|
|
31
|
+
@poll_timeout = poll_timeout
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
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']
|
|
62
|
+
|
|
63
|
+
File.open(@ipa_path, 'rb') do |f|
|
|
64
|
+
ops.each { |op| put_chunk_with_retry(f, op) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
md5 = Digest::MD5.file(@ipa_path).hexdigest
|
|
68
|
+
sha = Digest::SHA256.file(@ipa_path).hexdigest
|
|
69
|
+
|
|
70
|
+
@client.patch(
|
|
71
|
+
"/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}",
|
|
72
|
+
body: { uploaded: true, source_file_checksums: { md5: md5, sha256: sha } }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
final = poll_until_terminal(build_upload_id)
|
|
76
|
+
{ build_upload_id: build_upload_id, final_state: final }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def put_chunk_with_retry(file, operation)
|
|
82
|
+
# Defense-in-depth: Apple's signed URLs are always https. If the
|
|
83
|
+
# server ever returns an http:// URL, refuse to PUT the chunk — that
|
|
84
|
+
# would leak the .ipa bytes (and auth headers) in clear text.
|
|
85
|
+
scheme = URI.parse(operation['url'].to_s).scheme
|
|
86
|
+
raise "refusing non-https upload URL (scheme=#{scheme.inspect})" unless scheme == 'https'
|
|
87
|
+
|
|
88
|
+
file.seek(operation['offset'])
|
|
89
|
+
bytes = file.read(operation['length'])
|
|
90
|
+
attempts = 0
|
|
91
|
+
begin
|
|
92
|
+
conn = Faraday.new { |f| f.adapter Faraday.default_adapter }
|
|
93
|
+
resp = conn.public_send(operation['method'].downcase) do |req|
|
|
94
|
+
req.url operation['url']
|
|
95
|
+
(operation['requestHeaders'] || []).each { |h| req.headers[h['name']] = h['value'] }
|
|
96
|
+
req.body = bytes
|
|
97
|
+
end
|
|
98
|
+
raise "chunk PUT failed #{resp.status}" unless resp.status.between?(200, 299)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
attempts += 1
|
|
101
|
+
retry if attempts <= CHUNK_RETRIES
|
|
102
|
+
raise
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def poll_until_terminal(build_upload_id)
|
|
107
|
+
deadline = Time.now + @poll_timeout
|
|
108
|
+
loop do
|
|
109
|
+
resp = @client.get("/api/v1/organizations/#{@org_id}/builds/asc_upload/#{build_upload_id}")
|
|
110
|
+
state = resp[:data]['apple_state']
|
|
111
|
+
return state if TERMINAL_APPLE_STATES.include?(state)
|
|
112
|
+
return 'TIMEOUT' if Time.now > deadline
|
|
113
|
+
|
|
114
|
+
sleep @poll_interval
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|