mysigner 0.1.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 +7 -0
- data/.DS_Store +0 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +137 -0
- data/LICENSE +201 -0
- data/MANUAL_TEST.md +341 -0
- data/README.md +493 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/mysigner +5 -0
- data/lib/mysigner/build/android_executor.rb +367 -0
- data/lib/mysigner/build/android_parser.rb +293 -0
- data/lib/mysigner/build/configurator.rb +126 -0
- data/lib/mysigner/build/detector.rb +388 -0
- data/lib/mysigner/build/error_analyzer.rb +193 -0
- data/lib/mysigner/build/executor.rb +176 -0
- data/lib/mysigner/build/parser.rb +206 -0
- data/lib/mysigner/cli/auth_commands.rb +1381 -0
- data/lib/mysigner/cli/build_commands.rb +2095 -0
- data/lib/mysigner/cli/concerns/actionable_suggestions.rb +500 -0
- data/lib/mysigner/cli/concerns/api_helpers.rb +131 -0
- data/lib/mysigner/cli/concerns/error_handlers.rb +446 -0
- data/lib/mysigner/cli/concerns/helpers.rb +63 -0
- data/lib/mysigner/cli/diagnostic_commands.rb +1034 -0
- data/lib/mysigner/cli/resource_commands.rb +2670 -0
- data/lib/mysigner/cli.rb +43 -0
- data/lib/mysigner/client.rb +189 -0
- data/lib/mysigner/config.rb +311 -0
- data/lib/mysigner/export/exporter.rb +150 -0
- data/lib/mysigner/signing/certificate_checker.rb +148 -0
- data/lib/mysigner/signing/keystore_manager.rb +239 -0
- data/lib/mysigner/signing/validator.rb +150 -0
- data/lib/mysigner/signing/wizard.rb +784 -0
- data/lib/mysigner/upload/app_store_automation.rb +402 -0
- data/lib/mysigner/upload/app_store_submission.rb +312 -0
- data/lib/mysigner/upload/play_store_uploader.rb +378 -0
- data/lib/mysigner/upload/uploader.rb +373 -0
- data/lib/mysigner/version.rb +3 -0
- data/lib/mysigner.rb +15 -0
- data/mysigner.gemspec +78 -0
- data/test_manual.rb +102 -0
- metadata +286 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
3
|
+
module Mysigner
|
|
4
|
+
module Upload
|
|
5
|
+
class AppStoreSubmission
|
|
6
|
+
class SubmissionError < Mysigner::Error; end
|
|
7
|
+
|
|
8
|
+
def initialize(client, organization_id, build_info, metadata_overrides: {}, override_sources: [])
|
|
9
|
+
@client = client
|
|
10
|
+
@organization_id = organization_id
|
|
11
|
+
@build_info = build_info # { bundle_id:, version:, build_number:, app_id:, build_id: }
|
|
12
|
+
@metadata_overrides = metadata_overrides || {}
|
|
13
|
+
@override_sources = Array(override_sources)
|
|
14
|
+
@override_lookup = build_override_lookup(@override_sources)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Submit build for App Store review
|
|
18
|
+
def submit_for_review!(automation: nil)
|
|
19
|
+
puts ""
|
|
20
|
+
puts "š¤ Preparing for App Store submission..."
|
|
21
|
+
puts ""
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
# Step 1: Fetch release metadata from My Signer API
|
|
25
|
+
merged = merge_metadata(fetch_release_metadata)
|
|
26
|
+
metadata = merged[:merged]
|
|
27
|
+
|
|
28
|
+
if metadata && metadata.any?
|
|
29
|
+
puts "ā Loaded release configuration from My Signer"
|
|
30
|
+
puts ""
|
|
31
|
+
display_metadata(metadata)
|
|
32
|
+
else
|
|
33
|
+
puts "ā ļø No release configuration found in My Signer"
|
|
34
|
+
puts " Create one at: #{@client.api_url}/organizations/#{@organization_id}/app_store_releases"
|
|
35
|
+
puts ""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Enrich build_info with config values for smart build selection
|
|
39
|
+
enriched_build_info = symbolize_keys(@build_info)
|
|
40
|
+
if metadata
|
|
41
|
+
# min_build_number: skip builds below this number
|
|
42
|
+
if metadata['build_number'] && !enriched_build_info[:build_number]
|
|
43
|
+
enriched_build_info[:min_build_number] = metadata['build_number'].to_i
|
|
44
|
+
end
|
|
45
|
+
# Use version_string from config if not specified
|
|
46
|
+
if metadata['version_string'] && !enriched_build_info[:version]
|
|
47
|
+
enriched_build_info[:version] = metadata['version_string']
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
automation_result = if automation
|
|
52
|
+
automation.perform!(
|
|
53
|
+
metadata: metadata,
|
|
54
|
+
build_info: enriched_build_info,
|
|
55
|
+
metadata_overrides: @metadata_overrides
|
|
56
|
+
)
|
|
57
|
+
else
|
|
58
|
+
guide_to_manual_submission(metadata)
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{ success: true, metadata: metadata, automation: automation_result }
|
|
63
|
+
|
|
64
|
+
rescue => e
|
|
65
|
+
raise SubmissionError, "Failed to prepare submission: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def fetch_release_metadata
|
|
72
|
+
# Fetch release metadata from My Signer API
|
|
73
|
+
begin
|
|
74
|
+
response = @client.get(
|
|
75
|
+
"/api/v1/organizations/#{@organization_id}/app_store_releases",
|
|
76
|
+
params: { bundle_id: @build_info[:bundle_id] }
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if response[:success]
|
|
80
|
+
data = response[:data]
|
|
81
|
+
# API returns { app_store_releases: [...] } - extract first release
|
|
82
|
+
if data.is_a?(Hash) && data['app_store_releases'].is_a?(Array)
|
|
83
|
+
data['app_store_releases'].first
|
|
84
|
+
elsif data.is_a?(Hash) && data['app_store_release']
|
|
85
|
+
# Single release format
|
|
86
|
+
data['app_store_release']
|
|
87
|
+
else
|
|
88
|
+
data
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
rescue Mysigner::NotFoundError
|
|
94
|
+
# No configuration found - that's okay
|
|
95
|
+
nil
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
puts "ā ļø Could not fetch release metadata: #{e.message}"
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Fetch release config and extract min_build_number for smart build selection
|
|
103
|
+
def fetch_release_config
|
|
104
|
+
metadata = fetch_release_metadata
|
|
105
|
+
return {} unless metadata
|
|
106
|
+
|
|
107
|
+
config = {}
|
|
108
|
+
config[:min_build_number] = metadata['build_number'].to_i if metadata['build_number']
|
|
109
|
+
config[:release_type] = metadata['release_type'] if metadata['release_type']
|
|
110
|
+
config[:earliest_release_date] = metadata['earliest_release_date'] if metadata['earliest_release_date']
|
|
111
|
+
config
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def merge_metadata(api_metadata)
|
|
115
|
+
api_data = stringify_keys(api_metadata || {})
|
|
116
|
+
overrides = stringify_keys(@metadata_overrides)
|
|
117
|
+
{
|
|
118
|
+
merged: deep_merge(api_data, overrides),
|
|
119
|
+
api: api_data,
|
|
120
|
+
overrides: overrides
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def guide_to_manual_submission(metadata)
|
|
125
|
+
puts "š Next Steps for App Store Submission"
|
|
126
|
+
puts "=" * 60
|
|
127
|
+
puts ""
|
|
128
|
+
puts "Your build is uploaded to App Store Connect!"
|
|
129
|
+
puts ""
|
|
130
|
+
puts "To submit for review:"
|
|
131
|
+
puts " 1. Wait for Apple to process the build (5-15 minutes)"
|
|
132
|
+
puts " 2. Open App Store Connect:"
|
|
133
|
+
puts " https://appstoreconnect.apple.com"
|
|
134
|
+
puts " 3. Select your app and go to 'App Store' tab"
|
|
135
|
+
puts " 4. Create a new version or select existing one"
|
|
136
|
+
puts " 5. Select this build (#{@build_info[:version]} / #{@build_info[:build_number]})"
|
|
137
|
+
puts " 6. Add screenshots and metadata if not already present"
|
|
138
|
+
puts " 7. Click 'Submit for Review'"
|
|
139
|
+
puts ""
|
|
140
|
+
|
|
141
|
+
if metadata && metadata['auto_submit']
|
|
142
|
+
puts "š” Auto-submit enabled"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
puts ""
|
|
146
|
+
puts "Tip: rerun with --submit-for-review when ready"
|
|
147
|
+
puts " Use --wait/--asc-timeout-seconds to control polling"
|
|
148
|
+
puts ""
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def display_metadata(metadata)
|
|
152
|
+
puts "š Release Configuration:"
|
|
153
|
+
print_metadata_line('Bundle ID', metadata['bundle_identifier'], 'bundle_identifier')
|
|
154
|
+
print_metadata_line('App Name', metadata['app_name'], 'app_name') if metadata['app_name']
|
|
155
|
+
|
|
156
|
+
# Version info from config
|
|
157
|
+
print_metadata_line('Version String', metadata['version_string'], 'version_string') if metadata['version_string']
|
|
158
|
+
print_metadata_line('Min Build #', metadata['build_number'], 'build_number') if metadata['build_number']
|
|
159
|
+
|
|
160
|
+
if metadata['whats_new'] && !metadata['whats_new'].to_s.strip.empty?
|
|
161
|
+
puts " What's New: #{truncate(metadata['whats_new'])}#{override_suffix('whats_new')}"
|
|
162
|
+
else
|
|
163
|
+
puts " What's New: ā#{override_suffix('whats_new')}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
if metadata['promotional_text'] && !metadata['promotional_text'].to_s.strip.empty?
|
|
167
|
+
puts " Promo Text: #{truncate(metadata['promotional_text'])}#{override_suffix('promotional_text')}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
print_metadata_line('Support URL', metadata['support_url'], 'support_url')
|
|
171
|
+
print_metadata_line('Marketing URL', metadata['marketing_url'], 'marketing_url')
|
|
172
|
+
print_metadata_line('Privacy URL', metadata['privacy_policy_url'], 'privacy_policy_url')
|
|
173
|
+
|
|
174
|
+
# Release settings
|
|
175
|
+
release_type_label = format_release_type(metadata['release_type'])
|
|
176
|
+
print_metadata_line('Release Type', release_type_label, 'release_type')
|
|
177
|
+
if metadata['release_type'] == 'SCHEDULED' && metadata['earliest_release_date']
|
|
178
|
+
print_metadata_line('Scheduled Date', metadata['earliest_release_date'], 'earliest_release_date')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
print_metadata_toggle('Auto-submit', metadata['auto_submit'], 'auto_submit')
|
|
182
|
+
print_metadata_toggle('Phased Release', metadata['phased_release'], 'phased_release')
|
|
183
|
+
|
|
184
|
+
if metadata['localizations'].is_a?(Array) && metadata['localizations'].any?
|
|
185
|
+
first_locale = metadata['localizations'].first
|
|
186
|
+
locale_code = first_locale['locale'] || first_locale['localeCode'] || 'default'
|
|
187
|
+
puts " Localizations: #{metadata['localizations'].count} (showing #{locale_code})#{override_suffix('localizations')}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
warn_missing_submission_fields(metadata)
|
|
191
|
+
puts ""
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def format_release_type(release_type)
|
|
195
|
+
case release_type
|
|
196
|
+
when 'AFTER_APPROVAL' then 'After Approval (auto-release)'
|
|
197
|
+
when 'MANUAL' then 'Manual (hold for manual release)'
|
|
198
|
+
when 'SCHEDULED' then 'Scheduled'
|
|
199
|
+
else release_type || 'After Approval (default)'
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def deep_merge(base, overrides)
|
|
204
|
+
merged = base.dup
|
|
205
|
+
|
|
206
|
+
overrides.each do |key, value|
|
|
207
|
+
merged[key] = if merged[key].is_a?(Hash) && value.is_a?(Hash)
|
|
208
|
+
deep_merge(merged[key], value)
|
|
209
|
+
else
|
|
210
|
+
value
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
merged
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def stringify_keys(object)
|
|
218
|
+
case object
|
|
219
|
+
when Hash
|
|
220
|
+
object.each_with_object({}) do |(k, v), memo|
|
|
221
|
+
memo[k.to_s] = stringify_keys(v)
|
|
222
|
+
end
|
|
223
|
+
when Array
|
|
224
|
+
object.map { |item| stringify_keys(item) }
|
|
225
|
+
else
|
|
226
|
+
object
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def symbolize_keys(object)
|
|
231
|
+
case object
|
|
232
|
+
when Hash
|
|
233
|
+
object.each_with_object({}) do |(k, v), memo|
|
|
234
|
+
memo[k.to_sym] = symbolize_keys(v)
|
|
235
|
+
end
|
|
236
|
+
when Array
|
|
237
|
+
object.map { |item| symbolize_keys(item) }
|
|
238
|
+
else
|
|
239
|
+
object
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def truncate(text, max = 100)
|
|
244
|
+
text = text.to_s
|
|
245
|
+
return 'ā' if text.strip.empty?
|
|
246
|
+
|
|
247
|
+
text.length > max ? "#{text[0, max]}..." : text
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def print_metadata_line(label, value, key)
|
|
251
|
+
display = value && !value.to_s.strip.empty? ? value : 'ā'
|
|
252
|
+
puts " #{label}: #{display}#{override_suffix(key)}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def print_metadata_toggle(label, value, key)
|
|
256
|
+
human = value.nil? ? 'ā' : (value ? 'Yes' : 'No')
|
|
257
|
+
puts " #{label}: #{human}#{override_suffix(key)}"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def override_suffix(key)
|
|
261
|
+
sources = Array(@override_lookup[key])
|
|
262
|
+
return '' if sources.empty?
|
|
263
|
+
|
|
264
|
+
formatted = sources.map do |source|
|
|
265
|
+
case source[:type]
|
|
266
|
+
when :inline then 'CLI flag'
|
|
267
|
+
when :file
|
|
268
|
+
File.basename(source[:path])
|
|
269
|
+
else
|
|
270
|
+
source[:type].to_s
|
|
271
|
+
end
|
|
272
|
+
end.uniq
|
|
273
|
+
|
|
274
|
+
" (override: #{formatted.join(', ')})"
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def build_override_lookup(sources)
|
|
278
|
+
lookup = Hash.new { |h, k| h[k] = [] }
|
|
279
|
+
sources.each do |source|
|
|
280
|
+
Array(source[:keys]).each do |key|
|
|
281
|
+
lookup[key] << source
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
lookup
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def warn_missing_submission_fields(metadata)
|
|
288
|
+
return unless metadata['auto_submit']
|
|
289
|
+
|
|
290
|
+
# Get version to check if first version
|
|
291
|
+
version_string = metadata['version_string'] || metadata['version'] || '1.0'
|
|
292
|
+
is_first_version = version_string.split('.').first.to_i <= 1
|
|
293
|
+
|
|
294
|
+
warnings = []
|
|
295
|
+
# What's New only required for updates (version > 1.0)
|
|
296
|
+
warnings << "Missing What's New copy (required for version updates)" if !is_first_version && metadata['whats_new'].to_s.strip.empty?
|
|
297
|
+
|
|
298
|
+
# Support URL may already be in App Store Connect, so just note it
|
|
299
|
+
if metadata['support_url'].to_s.strip.empty?
|
|
300
|
+
warnings << "Support URL not configured in My Signer (will use App Store Connect value if available)"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
return if warnings.empty?
|
|
304
|
+
|
|
305
|
+
puts " ā ļø Notes:" unless warnings.empty?
|
|
306
|
+
warnings.each { |msg| puts " - #{msg}" }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'stringio'
|
|
3
|
+
|
|
4
|
+
module Mysigner
|
|
5
|
+
module Upload
|
|
6
|
+
class PlayStoreUploader
|
|
7
|
+
class UploadError < Mysigner::Error; end
|
|
8
|
+
class CredentialsError < UploadError; end
|
|
9
|
+
class TrackError < UploadError; end
|
|
10
|
+
|
|
11
|
+
# Special error for when AAB uploaded but track assignment failed
|
|
12
|
+
# This carries the version_code so it can be saved to prevent conflicts
|
|
13
|
+
class PartialUploadError < UploadError
|
|
14
|
+
attr_reader :version_code
|
|
15
|
+
|
|
16
|
+
def initialize(message, version_code:)
|
|
17
|
+
super(message)
|
|
18
|
+
@version_code = version_code
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
VALID_TRACKS = %w[internal alpha beta production].freeze
|
|
23
|
+
SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(aab_path:, service_account_json:, package_name:)
|
|
26
|
+
@aab_path = File.expand_path(aab_path)
|
|
27
|
+
@service_account_json = service_account_json
|
|
28
|
+
@package_name = package_name
|
|
29
|
+
|
|
30
|
+
validate_aab!
|
|
31
|
+
setup_google_client!
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Upload AAB and optionally assign to a track
|
|
35
|
+
# @param track [String] Track to assign: internal, alpha, beta, production
|
|
36
|
+
# @param release_notes [Hash] Localized release notes { 'en-US' => 'What\'s new...' }
|
|
37
|
+
# @param user_fraction [Float] Rollout percentage (0.0-1.0) for staged rollouts
|
|
38
|
+
# @return [Hash] Upload result with version_code and track info
|
|
39
|
+
def upload!(track: 'internal', release_notes: nil, user_fraction: nil)
|
|
40
|
+
@current_track = track # Store for error messages
|
|
41
|
+
say_uploading(track)
|
|
42
|
+
|
|
43
|
+
version_code = nil
|
|
44
|
+
|
|
45
|
+
begin
|
|
46
|
+
# 1. Create an edit
|
|
47
|
+
edit = create_edit
|
|
48
|
+
|
|
49
|
+
# 2. Upload the AAB
|
|
50
|
+
bundle = upload_bundle(edit.id)
|
|
51
|
+
version_code = bundle.version_code
|
|
52
|
+
|
|
53
|
+
say_upload_success(version_code)
|
|
54
|
+
|
|
55
|
+
# 3. Assign to track with release
|
|
56
|
+
if track
|
|
57
|
+
assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 4. Commit the edit
|
|
61
|
+
commit_edit(edit.id)
|
|
62
|
+
|
|
63
|
+
say_success(track, version_code)
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
success: true,
|
|
67
|
+
version_code: version_code,
|
|
68
|
+
track: track,
|
|
69
|
+
package_name: @package_name
|
|
70
|
+
}
|
|
71
|
+
rescue Google::Apis::ClientError => e
|
|
72
|
+
error_message = parse_google_error(e)
|
|
73
|
+
# If AAB was uploaded, raise PartialUploadError so CLI can save the version
|
|
74
|
+
if version_code
|
|
75
|
+
raise PartialUploadError.new("Google Play API error: #{error_message}", version_code: version_code)
|
|
76
|
+
else
|
|
77
|
+
raise UploadError, "Google Play API error: #{error_message}"
|
|
78
|
+
end
|
|
79
|
+
rescue PartialUploadError
|
|
80
|
+
# Re-raise as-is
|
|
81
|
+
raise
|
|
82
|
+
rescue => e
|
|
83
|
+
if version_code
|
|
84
|
+
raise PartialUploadError.new("Upload failed: #{e.message}", version_code: version_code)
|
|
85
|
+
else
|
|
86
|
+
raise UploadError, "Upload failed: #{e.message}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Upload AAB only (without assigning to track)
|
|
92
|
+
def upload_bundle_only!
|
|
93
|
+
say_uploading(nil)
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
edit = create_edit
|
|
97
|
+
bundle = upload_bundle(edit.id)
|
|
98
|
+
version_code = bundle.version_code
|
|
99
|
+
|
|
100
|
+
say_upload_success(version_code)
|
|
101
|
+
|
|
102
|
+
# Don't assign to track, just commit
|
|
103
|
+
commit_edit(edit.id)
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
success: true,
|
|
107
|
+
version_code: version_code,
|
|
108
|
+
package_name: @package_name
|
|
109
|
+
}
|
|
110
|
+
rescue Google::Apis::ClientError => e
|
|
111
|
+
error_message = parse_google_error(e)
|
|
112
|
+
raise UploadError, "Google Play API error: #{error_message}"
|
|
113
|
+
rescue => e
|
|
114
|
+
raise UploadError, "Upload failed: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Assign an existing version code to a track
|
|
119
|
+
def assign_existing_to_track!(version_code, track:, release_notes: nil, user_fraction: nil)
|
|
120
|
+
@current_track = track # Store for error messages
|
|
121
|
+
begin
|
|
122
|
+
edit = create_edit
|
|
123
|
+
assign_to_track(edit.id, track, version_code, release_notes: release_notes, user_fraction: user_fraction)
|
|
124
|
+
commit_edit(edit.id)
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
success: true,
|
|
128
|
+
version_code: version_code,
|
|
129
|
+
track: track,
|
|
130
|
+
package_name: @package_name
|
|
131
|
+
}
|
|
132
|
+
rescue Google::Apis::ClientError => e
|
|
133
|
+
error_message = parse_google_error(e)
|
|
134
|
+
raise TrackError, "Failed to assign to track: #{error_message}"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def validate_aab!
|
|
141
|
+
unless File.exist?(@aab_path)
|
|
142
|
+
raise UploadError, "AAB file not found: #{@aab_path}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
unless @aab_path.end_with?('.aab')
|
|
146
|
+
raise UploadError, "Invalid file type: #{@aab_path} (must be .aab)"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
file_size = File.size(@aab_path)
|
|
150
|
+
if file_size < 10_000
|
|
151
|
+
raise UploadError, "AAB file seems too small: #{file_size} bytes (possible corruption)"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def setup_google_client!
|
|
156
|
+
require 'googleauth'
|
|
157
|
+
require 'google/apis/androidpublisher_v3'
|
|
158
|
+
|
|
159
|
+
# Parse and validate service account JSON
|
|
160
|
+
begin
|
|
161
|
+
@credentials_data = JSON.parse(@service_account_json)
|
|
162
|
+
rescue JSON::ParserError => e
|
|
163
|
+
raise CredentialsError, "Invalid service account JSON: #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Build authorization
|
|
167
|
+
@auth = Google::Auth::ServiceAccountCredentials.make_creds(
|
|
168
|
+
json_key_io: StringIO.new(@service_account_json),
|
|
169
|
+
scope: SCOPE
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Create service
|
|
173
|
+
@service = Google::Apis::AndroidpublisherV3::AndroidPublisherService.new
|
|
174
|
+
@service.authorization = @auth
|
|
175
|
+
@service.client_options.open_timeout_sec = 30
|
|
176
|
+
@service.client_options.read_timeout_sec = 300 # Large file uploads need time
|
|
177
|
+
@service.request_options.retries = 3
|
|
178
|
+
|
|
179
|
+
rescue LoadError => e
|
|
180
|
+
raise CredentialsError, "Google API client not installed. Run: gem install google-api-client"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def create_edit
|
|
184
|
+
edit = Google::Apis::AndroidpublisherV3::AppEdit.new
|
|
185
|
+
@service.insert_edit(@package_name, edit)
|
|
186
|
+
rescue Google::Apis::ClientError => e
|
|
187
|
+
if e.message.include?("Package not found") || e.status_code == 404
|
|
188
|
+
raise UploadError, first_upload_error_message
|
|
189
|
+
end
|
|
190
|
+
raise
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def first_upload_error_message
|
|
194
|
+
<<~MSG
|
|
195
|
+
Google Play API can't find package '#{@package_name}'.
|
|
196
|
+
|
|
197
|
+
This happens when no build has been uploaded to this app yet.
|
|
198
|
+
Google Play API requires the FIRST build to be uploaded manually.
|
|
199
|
+
|
|
200
|
+
To fix:
|
|
201
|
+
1. Build AAB: mysigner android build
|
|
202
|
+
2. Go to Play Console ā Your App ā Internal testing ā Create release
|
|
203
|
+
3. Upload the AAB file shown in the build output
|
|
204
|
+
4. Save the release (don't need to roll out)
|
|
205
|
+
|
|
206
|
+
After that, mysigner ship will work for all future uploads.
|
|
207
|
+
MSG
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def upload_bundle(edit_id)
|
|
211
|
+
puts "š¦ Uploading AAB (#{format_bytes(File.size(@aab_path))})..."
|
|
212
|
+
puts ""
|
|
213
|
+
|
|
214
|
+
begin
|
|
215
|
+
result = @service.upload_edit_bundle(
|
|
216
|
+
@package_name,
|
|
217
|
+
edit_id,
|
|
218
|
+
upload_source: @aab_path,
|
|
219
|
+
content_type: 'application/octet-stream'
|
|
220
|
+
)
|
|
221
|
+
result
|
|
222
|
+
rescue Google::Apis::ClientError => e
|
|
223
|
+
error_msg = parse_google_error(e)
|
|
224
|
+
raise UploadError, "Bundle upload failed: #{error_msg}"
|
|
225
|
+
rescue => e
|
|
226
|
+
raise UploadError, "Bundle upload failed: #{e.message}"
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def assign_to_track(edit_id, track, version_code, release_notes: nil, user_fraction: nil)
|
|
231
|
+
unless VALID_TRACKS.include?(track)
|
|
232
|
+
raise TrackError, "Invalid track '#{track}'. Valid tracks: #{VALID_TRACKS.join(', ')}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
puts "š Assigning to #{track} track..."
|
|
236
|
+
|
|
237
|
+
# Build release
|
|
238
|
+
release = Google::Apis::AndroidpublisherV3::TrackRelease.new(
|
|
239
|
+
version_codes: [version_code.to_s],
|
|
240
|
+
status: user_fraction ? 'inProgress' : 'completed'
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Add release notes if provided
|
|
244
|
+
if release_notes && release_notes.any?
|
|
245
|
+
release.release_notes = release_notes.map do |lang, text|
|
|
246
|
+
Google::Apis::AndroidpublisherV3::LocalizedText.new(
|
|
247
|
+
language: lang,
|
|
248
|
+
text: text
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Add user fraction for staged rollouts
|
|
254
|
+
if user_fraction
|
|
255
|
+
release.user_fraction = user_fraction
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Build track update
|
|
259
|
+
track_obj = Google::Apis::AndroidpublisherV3::Track.new(
|
|
260
|
+
track: track,
|
|
261
|
+
releases: [release]
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@service.update_edit_track(@package_name, edit_id, track, track_obj)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def commit_edit(edit_id)
|
|
268
|
+
puts "š¾ Committing changes..."
|
|
269
|
+
begin
|
|
270
|
+
# Try with changesNotSentForReview first (for apps without managed review)
|
|
271
|
+
@service.commit_edit(@package_name, edit_id, changes_not_sent_for_review: true)
|
|
272
|
+
rescue Google::Apis::ClientError => e
|
|
273
|
+
error_text = e.message.to_s
|
|
274
|
+
# Also check body if present
|
|
275
|
+
if e.respond_to?(:body) && e.body
|
|
276
|
+
error_text += " #{e.body}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if error_text.include?('changesNotSentForReview')
|
|
280
|
+
# App has managed review enabled, commit without the flag
|
|
281
|
+
@service.commit_edit(@package_name, edit_id)
|
|
282
|
+
else
|
|
283
|
+
raise
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def parse_google_error(error)
|
|
289
|
+
begin
|
|
290
|
+
body = nil
|
|
291
|
+
if error.respond_to?(:body) && error.body
|
|
292
|
+
body = JSON.parse(error.body) rescue nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
message = body&.dig('error', 'message') || error.message
|
|
296
|
+
details = body&.dig('error', 'errors')&.map { |e| e['message'] }&.join('; ')
|
|
297
|
+
full_message = details ? "#{message} (#{details})" : message
|
|
298
|
+
|
|
299
|
+
# Provide helpful context for common errors
|
|
300
|
+
case full_message.to_s.downcase
|
|
301
|
+
when /package.*not found/i, /app not found/i
|
|
302
|
+
"#{full_message}\n\nš” Make sure the package name '#{@package_name}' matches your app in Google Play Console."
|
|
303
|
+
when /not authorized/i, /permission denied/i, /forbidden/i
|
|
304
|
+
"#{full_message}\n\nš” Check that your service account has Editor or Admin access to the app in Google Play Console."
|
|
305
|
+
when /version.*code.*already/i, /already.*used/i
|
|
306
|
+
"#{full_message}\n\nš” Version code already exists. Increment versionCode in android/app/build.gradle and rebuild."
|
|
307
|
+
when /precondition.*check.*failed/i, /precondition.*failed/i
|
|
308
|
+
track_name = @current_track || "this track"
|
|
309
|
+
"#{full_message}\n\n" \
|
|
310
|
+
"š” Google Play Console requires setup before publishing to #{track_name}:\n\n" \
|
|
311
|
+
" For PRODUCTION track:\n" \
|
|
312
|
+
" ⢠Complete store listing (description, screenshots, etc.)\n" \
|
|
313
|
+
" ⢠Set content rating\n" \
|
|
314
|
+
" ⢠Configure pricing & distribution\n\n" \
|
|
315
|
+
" For BETA/ALPHA tracks:\n" \
|
|
316
|
+
" ⢠Create a closed/open testing track in Play Console\n" \
|
|
317
|
+
" ⢠Add at least one tester email\n\n" \
|
|
318
|
+
" For INTERNAL track:\n" \
|
|
319
|
+
" ⢠Add internal testers in Play Console\n\n" \
|
|
320
|
+
" ā
Your AAB was uploaded successfully!\n" \
|
|
321
|
+
" ā Go to Play Console to complete track setup, then use:\n" \
|
|
322
|
+
" mysigner submit #{track_name} --platform android --version-code VERSION"
|
|
323
|
+
when /invalid request/i
|
|
324
|
+
"#{full_message}\n\nš” Common causes:\n" \
|
|
325
|
+
" ⢠Version code not found on Google Play (must upload first)\n" \
|
|
326
|
+
" ⢠App not created in Google Play Console\n" \
|
|
327
|
+
" ⢠Service account missing permissions"
|
|
328
|
+
when /signing/i, /signature/i
|
|
329
|
+
"#{full_message}\n\nš” The AAB may not be signed with the correct key. Check your keystore matches what's registered in Play Console."
|
|
330
|
+
else
|
|
331
|
+
full_message
|
|
332
|
+
end
|
|
333
|
+
rescue => parse_error
|
|
334
|
+
"#{error.message} (parsing error: #{parse_error.message})"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def say_uploading(track)
|
|
339
|
+
puts "āļø Uploading to Google Play#{track ? " (#{track} track)" : ''}..."
|
|
340
|
+
puts ""
|
|
341
|
+
puts "AAB: #{File.basename(@aab_path)}"
|
|
342
|
+
puts "Size: #{format_bytes(File.size(@aab_path))}"
|
|
343
|
+
puts "Package: #{@package_name}"
|
|
344
|
+
puts "Track: #{track || 'none (upload only)'}"
|
|
345
|
+
puts ""
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def say_upload_success(version_code)
|
|
349
|
+
puts "ā Bundle uploaded successfully (version code: #{version_code})"
|
|
350
|
+
puts ""
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def say_success(track, version_code)
|
|
354
|
+
puts ""
|
|
355
|
+
puts "=" * 80
|
|
356
|
+
puts "ā Upload complete!"
|
|
357
|
+
puts "=" * 80
|
|
358
|
+
puts ""
|
|
359
|
+
puts "š Your app is now on Google Play (#{track} track)"
|
|
360
|
+
puts ""
|
|
361
|
+
puts "Version Code: #{version_code}"
|
|
362
|
+
puts "Package: #{@package_name}"
|
|
363
|
+
puts "Track: #{track}"
|
|
364
|
+
puts ""
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def format_bytes(bytes)
|
|
368
|
+
if bytes < 1024
|
|
369
|
+
"#{bytes} B"
|
|
370
|
+
elsif bytes < 1024 * 1024
|
|
371
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
372
|
+
else
|
|
373
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|