@ahmedsamirelsaka/react-native-local-release 2.0.0-rc.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/CREDENTIALS.md +79 -0
  3. package/LICENSE +21 -0
  4. package/README.md +112 -0
  5. package/SECURITY.md +44 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +27 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +229 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/credentials.d.ts +27 -0
  15. package/dist/credentials.d.ts.map +1 -0
  16. package/dist/credentials.js +317 -0
  17. package/dist/credentials.js.map +1 -0
  18. package/dist/detect.d.ts +7 -0
  19. package/dist/detect.d.ts.map +1 -0
  20. package/dist/detect.js +326 -0
  21. package/dist/detect.js.map +1 -0
  22. package/dist/fs.d.ts +15 -0
  23. package/dist/fs.d.ts.map +1 -0
  24. package/dist/fs.js +79 -0
  25. package/dist/fs.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +6 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/install.d.ts +5 -0
  31. package/dist/install.d.ts.map +1 -0
  32. package/dist/install.js +338 -0
  33. package/dist/install.js.map +1 -0
  34. package/dist/logging.d.ts +15 -0
  35. package/dist/logging.d.ts.map +1 -0
  36. package/dist/logging.js +84 -0
  37. package/dist/logging.js.map +1 -0
  38. package/dist/patch.d.ts +39 -0
  39. package/dist/patch.d.ts.map +1 -0
  40. package/dist/patch.js +352 -0
  41. package/dist/patch.js.map +1 -0
  42. package/dist/paths.d.ts +14 -0
  43. package/dist/paths.d.ts.map +1 -0
  44. package/dist/paths.js +65 -0
  45. package/dist/paths.js.map +1 -0
  46. package/dist/process.d.ts +26 -0
  47. package/dist/process.d.ts.map +1 -0
  48. package/dist/process.js +43 -0
  49. package/dist/process.js.map +1 -0
  50. package/dist/prompt.d.ts +9 -0
  51. package/dist/prompt.d.ts.map +1 -0
  52. package/dist/prompt.js +70 -0
  53. package/dist/prompt.js.map +1 -0
  54. package/dist/types.d.ts +153 -0
  55. package/dist/types.d.ts.map +1 -0
  56. package/dist/types.js +2 -0
  57. package/dist/types.js.map +1 -0
  58. package/docs/ARCHITECTURE.md +25 -0
  59. package/docs/MIGRATION.md +28 -0
  60. package/docs/PUBLISHING.md +128 -0
  61. package/docs/SUPPORT.md +17 -0
  62. package/install.mjs +6 -0
  63. package/package.json +73 -0
  64. package/schema/release.config.schema.json +209 -0
  65. package/template/android/keystore.properties.example +7 -0
  66. package/template/android/proguard-rules.rn-local-release.pro +28 -0
  67. package/template/docs/LOCAL_RELEASE.md +85 -0
  68. package/template/fastlane/.env.example +20 -0
  69. package/template/fastlane/Appfile +10 -0
  70. package/template/fastlane/Fastfile +203 -0
  71. package/template/fastlane/lib/rn_local_release.rb +700 -0
  72. package/template/ios/exportOptions.plist +18 -0
  73. package/template/scripts/print-android-signing-fingerprints.sh +62 -0
  74. package/template/secrets/.gitignore +3 -0
  75. package/template/secrets/README.md +12 -0
@@ -0,0 +1,700 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded via Fastlane `import` so DSL methods (sh, gradle, UI) resolve.
4
+
5
+ require "json"
6
+ require "digest"
7
+ require "fileutils"
8
+ require "pathname"
9
+ require "time"
10
+ require "open3"
11
+ require "shellwords"
12
+
13
+ def repo_root
14
+ @repo_root ||= File.expand_path("../..", __dir__)
15
+ end
16
+
17
+ def config_path
18
+ File.join(repo_root, "fastlane", "release.config.json")
19
+ end
20
+
21
+ def config
22
+ @config ||= begin
23
+ UI.user_error!("Missing #{config_path}. Run the rn-local-release installer.") unless File.exist?(config_path)
24
+ JSON.parse(File.read(config_path))
25
+ end
26
+ end
27
+
28
+ def log_release_error!(message, error = nil)
29
+ return unless config.dig("logging", "enabled") != false
30
+
31
+ dir = File.join(repo_root, config.dig("logging", "directory") || "logs")
32
+ FileUtils.mkdir_p(dir)
33
+ File.chmod(0o700, dir) rescue nil
34
+ stamp = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
35
+ path = File.join(dir, "fastlane-#{stamp}.log")
36
+ body = "[#{Time.now.utc.iso8601}] ERROR #{message}\n"
37
+ body += "#{error}\n" if error
38
+ body = body.gsub(/password[=:].*/i, "password=[REDACTED]")
39
+ .gsub(/BEGIN [A-Z ]*PRIVATE KEY[\s\S]*?END [A-Z ]*PRIVATE KEY/, "[REDACTED KEY]")
40
+ File.write(path, body)
41
+ File.chmod(0o600, path) rescue nil
42
+ UI.important("Error details written to #{path}")
43
+ end
44
+
45
+ def require_env!(name)
46
+ value = ENV[name].to_s.strip
47
+ UI.user_error!("Missing required environment variable: #{name}. See fastlane/.env.example.") if value.empty?
48
+ value
49
+ end
50
+
51
+ def resolve_path(path)
52
+ return nil if path.nil? || path.strip.empty?
53
+ root_real = File.realpath(repo_root)
54
+ candidate = File.expand_path(path.strip, repo_root)
55
+ UI.user_error!("File not found: #{candidate}") unless File.exist?(candidate)
56
+ UI.user_error!("Refusing symlink path: #{candidate}") if File.symlink?(candidate)
57
+ real = File.realpath(candidate)
58
+ unless real == root_real || real.start_with?(root_real + File::SEPARATOR) || path.strip.start_with?("/")
59
+ # Absolute external vault paths are allowed only when explicitly absolute and outside repo.
60
+ end
61
+ if path.strip.start_with?("/")
62
+ # External absolute path (Keychain vault). Require owner-only mode.
63
+ mode = File.stat(real).mode & 0o777
64
+ UI.user_error!("Insecure permissions on #{real}") if (mode & 0o077) != 0
65
+ return real
66
+ end
67
+ relative = Pathname.new(real).relative_path_from(Pathname.new(root_real)).to_s
68
+ UI.user_error!("Path escapes project root: #{path}") if relative.start_with?("..")
69
+ real
70
+ end
71
+
72
+ def skip_flag?(options, key)
73
+ value = options[key]
74
+ value == true || value.to_s.strip.downcase == "true"
75
+ end
76
+
77
+ def require_option!(options, key)
78
+ value = options[key].to_s.strip
79
+ UI.user_error!("Missing required option: #{key}") if value.empty?
80
+ value
81
+ end
82
+
83
+ def ensure_clean_git!
84
+ return unless config.dig("gates", "requireCleanGitForPromote") != false
85
+
86
+ dirty = Dir.chdir(repo_root) do
87
+ stdout, status = Open3.capture2("git", "status", "--porcelain")
88
+ UI.user_error!("git status failed") unless status.success?
89
+ stdout.to_s.strip
90
+ end
91
+ UI.user_error!("Git working tree is dirty. Commit or stash changes before production promotion.") unless dirty.empty?
92
+ end
93
+
94
+ def load_asc_api_key
95
+ app_store_connect_api_key(
96
+ key_id: require_env!("ASC_KEY_ID"),
97
+ issuer_id: require_env!("ASC_ISSUER_ID"),
98
+ key_filepath: resolve_path(require_env!("ASC_KEY_FILEPATH")),
99
+ duration: 1200,
100
+ in_house: false
101
+ )
102
+ end
103
+
104
+ def play_json_key_path
105
+ resolve_path(require_env!("PLAY_JSON_KEY_PATH"))
106
+ end
107
+
108
+ def load_keystore_properties
109
+ path = File.join(repo_root, "android", "keystore.properties")
110
+ return {} unless File.exist?(path)
111
+
112
+ mode = File.stat(path).mode & 0o777
113
+ UI.user_error!("Insecure permissions on #{path} (#{mode.to_s(8)}). Run: chmod 600 #{path}") if (mode & 0o077) != 0
114
+
115
+ props = {}
116
+ File.foreach(path) do |line|
117
+ next if line.strip.empty? || line.strip.start_with?("#")
118
+ key, value = line.split("=", 2)
119
+ next if key.nil? || value.nil?
120
+ props[key.strip] = value.strip
121
+ end
122
+ props
123
+ end
124
+
125
+ def signing_value(name, default: nil)
126
+ from_env = ENV[name].to_s.strip
127
+ return from_env unless from_env.empty?
128
+
129
+ from_file = load_keystore_properties[name].to_s.strip
130
+ return from_file unless from_file.empty?
131
+
132
+ return default unless default.nil?
133
+
134
+ UI.user_error!("Missing #{name}. Set it in fastlane/.env, Keychain, or android/keystore.properties.")
135
+ end
136
+
137
+ def apply_android_signing_env!
138
+ {
139
+ "MYAPP_UPLOAD_STORE_FILE" => signing_value("MYAPP_UPLOAD_STORE_FILE", default: "keystore.jks"),
140
+ "MYAPP_UPLOAD_STORE_PASSWORD" => signing_value("MYAPP_UPLOAD_STORE_PASSWORD"),
141
+ "MYAPP_UPLOAD_KEY_ALIAS" => signing_value("MYAPP_UPLOAD_KEY_ALIAS", default: "key0"),
142
+ "MYAPP_UPLOAD_KEY_PASSWORD" => signing_value("MYAPP_UPLOAD_KEY_PASSWORD")
143
+ }.each { |key, value| ENV[key] = value }
144
+ end
145
+
146
+ def package_json_path
147
+ File.join(repo_root, "package.json")
148
+ end
149
+
150
+ def android_build_gradle_path
151
+ File.join(repo_root, config.dig("android", "buildGradlePath"))
152
+ end
153
+
154
+ def ios_pbxproj_path
155
+ project = config.dig("ios", "project")
156
+ File.join(repo_root, project, "project.pbxproj")
157
+ end
158
+
159
+ def assert_semver!(version)
160
+ UI.user_error!("Invalid marketing version (SemVer required): #{version}") unless version.to_s.match?(/\A\d+\.\d+\.\d+([.-][A-Za-z0-9.-]+)?\z/)
161
+ end
162
+
163
+ def current_marketing_version
164
+ File.read(package_json_path)[/\"version\"\s*:\s*\"([^\"]+)\"/, 1] ||
165
+ UI.user_error!("Could not read version from package.json")
166
+ end
167
+
168
+ def current_android_build
169
+ text = File.read(android_build_gradle_path)
170
+ text[/versionCode\s*=\s*(\d+)/, 1] ||
171
+ text[/versionCode\s+(\d+)/, 1] ||
172
+ UI.user_error!("Could not read versionCode from Android build.gradle")
173
+ end
174
+
175
+ def current_ios_build
176
+ File.read(ios_pbxproj_path)[/CURRENT_PROJECT_VERSION = (\d+);/, 1] || "1"
177
+ end
178
+
179
+ def next_semver(version, bump)
180
+ assert_semver!(version)
181
+ parts = version.to_s.split(".")
182
+ parts << "0" while parts.length < 3
183
+ major, minor, patch = parts[0].to_i, parts[1].to_i, parts[2].to_i
184
+ case bump
185
+ when "major"
186
+ "#{major + 1}.0.0"
187
+ when "minor"
188
+ "#{major}.#{minor + 1}.0"
189
+ else
190
+ "#{major}.#{minor}.#{patch + 1}"
191
+ end
192
+ end
193
+
194
+ def ios_build_for(_marketing_version, android_build)
195
+ strategy = config.dig("ios", "buildNumberStrategy") || { "mode" => "increment" }
196
+ mode = strategy["mode"].to_s
197
+ if mode == "fixed"
198
+ value = strategy["value"].to_i
199
+ UI.user_error!("ios.buildNumberStrategy.value must be >= 1") if value < 1
200
+ value
201
+ else
202
+ android_build.to_i
203
+ end
204
+ end
205
+
206
+ def replace_string_literal!(path, version, pattern = nil)
207
+ text = File.read(path)
208
+ replaced = false
209
+ if pattern && !pattern.empty?
210
+ expected = pattern.sub("VERSION", version)
211
+ if text.include?(expected)
212
+ replaced = true
213
+ elsif text.sub!(/return\s+'[^']+'/, "return '#{version}'") ||
214
+ text.sub!(/return\s+"[^"]+"/, "return \"#{version}\"")
215
+ replaced = true
216
+ end
217
+ else
218
+ replaced = text.sub!(/return\s+'[^']+'/, "return '#{version}'") ||
219
+ text.sub!(/return\s+"[^"]+"/, "return \"#{version}\"")
220
+ end
221
+ UI.user_error!("Could not update string literal version in #{path}") unless replaced
222
+ File.write(path, text)
223
+ end
224
+
225
+ def with_version_transaction(files)
226
+ backups = {}
227
+ files.each do |path|
228
+ next unless File.exist?(path)
229
+ backups[path] = File.read(path)
230
+ end
231
+ begin
232
+ yield
233
+ rescue StandardError => e
234
+ backups.each { |path, contents| File.write(path, contents) }
235
+ log_release_error!("Version write failed; restored previous files", e)
236
+ raise
237
+ end
238
+ end
239
+
240
+ def write_aligned_versions!(marketing_version, android_build)
241
+ marketing_version = marketing_version.to_s.strip
242
+ android_build = android_build.to_i
243
+ assert_semver!(marketing_version)
244
+ UI.user_error!("Invalid Android build number: #{android_build}") if android_build < 1
245
+
246
+ previous_android = current_android_build.to_i
247
+ UI.user_error!("Refusing to downgrade Android versionCode (#{previous_android} -> #{android_build})") if android_build < previous_android
248
+
249
+ ios_build = ios_build_for(marketing_version, android_build)
250
+ sources = config.dig("version", "sources") || []
251
+ extras = config.dig("version", "extraFiles") || []
252
+ touched = (sources + extras).map { |s| File.join(repo_root, s["path"]) }
253
+
254
+ with_version_transaction(touched) do
255
+ sources.each do |source|
256
+ absolute = File.join(repo_root, source["path"])
257
+ case source["kind"]
258
+ when "packageJson"
259
+ package = File.read(absolute)
260
+ updated = package.sub(/\"version\"\s*:\s*\"[^\"]+\"/, "\"version\": \"#{marketing_version}\"")
261
+ UI.user_error!("Failed to update package.json version") if updated == package
262
+ File.write(absolute, updated)
263
+ when "androidGradle", "androidGradleKts"
264
+ gradle = File.read(absolute)
265
+ original = gradle.dup
266
+ if source["kind"] == "androidGradleKts" || absolute.end_with?(".kts")
267
+ gradle = gradle.sub(/versionCode\s*=\s*\d+/, "versionCode = #{android_build}")
268
+ gradle = gradle.sub(/versionName\s*=\s*\"[^\"]+\"/, "versionName = \"#{marketing_version}\"")
269
+ # Also support space form
270
+ gradle = gradle.sub(/versionCode\s+\d+/, "versionCode #{android_build}") if gradle == original || !gradle.include?("versionCode = #{android_build}")
271
+ gradle = gradle.sub(/versionName\s+\"[^\"]+\"/, "versionName \"#{marketing_version}\"") unless gradle.include?("versionName = \"#{marketing_version}\"") || gradle.include?("versionName \"#{marketing_version}\"")
272
+ else
273
+ gradle = gradle.sub(/versionCode\s+\d+/, "versionCode #{android_build}")
274
+ gradle = gradle.sub(/versionName\s+\"[^\"]+\"/, "versionName \"#{marketing_version}\"")
275
+ end
276
+ UI.user_error!("Failed to update Android version fields in #{absolute}") if gradle == original
277
+ File.write(absolute, gradle)
278
+ when "iosPbxproj"
279
+ pbx = File.read(absolute)
280
+ original = pbx.dup
281
+ targets = Array(source["targets"]).map(&:to_s).reject(&:empty?)
282
+ if targets.empty?
283
+ pbx = pbx.gsub(/CURRENT_PROJECT_VERSION = \d+;/, "CURRENT_PROJECT_VERSION = #{ios_build};")
284
+ pbx = pbx.gsub(/MARKETING_VERSION = [^;]+;/, "MARKETING_VERSION = #{marketing_version};")
285
+ else
286
+ # Scope replacements to build settings blocks that mention the selected scheme/target name when possible.
287
+ targets.each do |target|
288
+ pbx = pbx.gsub(/(PRODUCT_NAME = #{Regexp.escape(target)};[\s\S]*?)CURRENT_PROJECT_VERSION = \d+;/) do
289
+ "#{Regexp.last_match(1)}CURRENT_PROJECT_VERSION = #{ios_build};"
290
+ end
291
+ pbx = pbx.gsub(/(PRODUCT_NAME = #{Regexp.escape(target)};[\s\S]*?)MARKETING_VERSION = [^;]+;/) do
292
+ "#{Regexp.last_match(1)}MARKETING_VERSION = #{marketing_version};"
293
+ end
294
+ end
295
+ # Fallback if scoped replace missed
296
+ if pbx == original
297
+ pbx = pbx.gsub(/CURRENT_PROJECT_VERSION = \d+;/, "CURRENT_PROJECT_VERSION = #{ios_build};")
298
+ pbx = pbx.gsub(/MARKETING_VERSION = [^;]+;/, "MARKETING_VERSION = #{marketing_version};")
299
+ end
300
+ end
301
+ UI.user_error!("Failed to update iOS version fields in #{absolute}") if pbx == original
302
+ File.write(absolute, pbx)
303
+ when "stringLiteral"
304
+ replace_string_literal!(absolute, marketing_version, source["pattern"])
305
+ else
306
+ UI.user_error!("Unknown version source kind: #{source['kind']}")
307
+ end
308
+ end
309
+
310
+ extras.each do |extra|
311
+ absolute = File.join(repo_root, extra["path"])
312
+ case extra["kind"]
313
+ when "stringLiteral"
314
+ replace_string_literal!(absolute, marketing_version, extra["pattern"])
315
+ else
316
+ UI.user_error!("Unknown extra version kind: #{extra['kind']}")
317
+ end
318
+ end
319
+ end
320
+
321
+ UI.success("Aligned app version to #{marketing_version} (Android #{android_build}, iOS #{ios_build})")
322
+ { version: marketing_version, android_build: android_build, ios_build: ios_build }
323
+ end
324
+
325
+ def bump_versions!(options = {})
326
+ marketing = options[:version].to_s.strip
327
+ if marketing.empty?
328
+ bump = options[:bump].to_s.strip
329
+ bump = config.dig("version", "defaultBump") if bump.empty?
330
+ bump = "patch" if bump.to_s.empty?
331
+ marketing = next_semver(current_marketing_version, bump)
332
+ end
333
+
334
+ build = options[:build].to_s.strip
335
+ build = if build.empty?
336
+ current_android_build.to_i + 1
337
+ else
338
+ build.to_i
339
+ end
340
+
341
+ write_aligned_versions!(marketing, build)
342
+ end
343
+
344
+ def parse_allowlisted_command(command)
345
+ trimmed = command.to_s.strip
346
+ UI.user_error!("Empty verify/build command") if trimmed.empty?
347
+ UI.user_error!("Refusing shell metacharacters in command: #{trimmed}") if trimmed.match?(/[;|&`$()<>]/) || trimmed.include?("\n")
348
+ parts = Shellwords.split(trimmed)
349
+ allowed = %w[yarn npm pnpm bundle node]
350
+ UI.user_error!("Command binary not allowlisted: #{parts[0]}") unless allowed.include?(parts[0])
351
+ parts
352
+ end
353
+
354
+ def run_argv_commands!(commands)
355
+ Dir.chdir(repo_root) do
356
+ Array(commands).each do |command|
357
+ parts = parse_allowlisted_command(command)
358
+ UI.message("$ #{parts.join(' ')}")
359
+ success = system(*parts)
360
+ UI.user_error!("Command failed: #{parts.join(' ')}") unless success
361
+ end
362
+ end
363
+ end
364
+
365
+ def compile_js!
366
+ command = config.dig("build", "typecheckCommand")
367
+ UI.user_error!("build.typecheckCommand is missing") if command.to_s.strip.empty?
368
+ run_argv_commands!([command])
369
+ end
370
+
371
+ def run_verify!
372
+ install = config.dig("verify", "install")
373
+ commands = config.dig("verify", "commands") || []
374
+ run_argv_commands!([install, *commands].compact.reject(&:empty?))
375
+ end
376
+
377
+ def ios_ipa_path
378
+ File.join(
379
+ repo_root,
380
+ config.dig("build", "iosOutputDir"),
381
+ config.dig("ios", "outputIpaName")
382
+ )
383
+ end
384
+
385
+ def android_aab_path
386
+ File.join(repo_root, config.dig("android", "aabPath"))
387
+ end
388
+
389
+ def build_ios_ipa!
390
+ cocoapods(
391
+ podfile: File.join(repo_root, "ios", "Podfile"),
392
+ try_repo_update_on_error: true
393
+ )
394
+ build_app(
395
+ workspace: File.join(repo_root, config.dig("ios", "workspace")),
396
+ scheme: config.dig("ios", "scheme"),
397
+ configuration: "Release",
398
+ clean: true,
399
+ export_method: config.dig("ios", "exportMethod"),
400
+ export_options: File.join(repo_root, config.dig("ios", "exportOptionsPath")),
401
+ output_directory: File.join(repo_root, config.dig("build", "iosOutputDir")),
402
+ output_name: config.dig("ios", "outputIpaName"),
403
+ xcargs: "-allowProvisioningUpdates"
404
+ )
405
+ end
406
+
407
+ def build_android_aab!
408
+ apply_android_signing_env!
409
+ gradle(
410
+ task: "bundle",
411
+ build_type: "Release",
412
+ project_dir: File.join(repo_root, "android")
413
+ )
414
+ end
415
+
416
+ def file_sha256(path)
417
+ Digest::SHA256.file(path).hexdigest
418
+ end
419
+
420
+ def assert_artifact!(path, label, max_mb)
421
+ UI.user_error!("Missing #{label}: #{path}") unless File.exist?(path)
422
+ size = File.size(path)
423
+ UI.user_error!("#{label} is empty: #{path}") if size < 1
424
+ if max_mb && size > (max_mb.to_f * 1024 * 1024)
425
+ UI.user_error!("#{label} exceeds budget #{max_mb} MB (actual #{(size / 1024.0 / 1024.0).round(1)} MB)")
426
+ end
427
+ size
428
+ end
429
+
430
+ def report_artifact_sizes!
431
+ ipa = ios_ipa_path
432
+ aab = android_aab_path
433
+ lines = []
434
+ lines << "IPA: #{(File.size(ipa) / 1024.0 / 1024.0).round(1)} MB (#{ipa})" if File.exist?(ipa)
435
+ lines << "AAB: #{(File.size(aab) / 1024.0 / 1024.0).round(1)} MB (#{aab})" if File.exist?(aab)
436
+ lines.each { |line| UI.important(line) }
437
+ end
438
+
439
+ def git_commit_sha
440
+ stdout, status = Open3.capture2("git", "-C", repo_root, "rev-parse", "HEAD")
441
+ status.success? ? stdout.strip : "unknown"
442
+ end
443
+
444
+ def git_dirty?
445
+ stdout, status = Open3.capture2("git", "-C", repo_root, "status", "--porcelain")
446
+ return false unless status.success?
447
+ !stdout.strip.empty?
448
+ end
449
+
450
+ def manifest_path(platform = nil)
451
+ case platform
452
+ when :ios then File.join(repo_root, "build", "release-manifest-ios.json")
453
+ when :android then File.join(repo_root, "build", "release-manifest-android.json")
454
+ else File.join(repo_root, "build", "release-manifest.json")
455
+ end
456
+ end
457
+
458
+ def upload_journal_path
459
+ File.join(repo_root, "build", "upload-journal.json")
460
+ end
461
+
462
+ def write_platform_manifest!(platform)
463
+ case platform
464
+ when :ios
465
+ size = assert_artifact!(ios_ipa_path, "iOS IPA", config.dig("build", "maxIpaSizeMb"))
466
+ payload = {
467
+ "platform" => "ios",
468
+ "version" => current_marketing_version,
469
+ "iosBuild" => current_ios_build.to_i,
470
+ "bundleIdentifier" => config.dig("app", "bundleIdentifier"),
471
+ "gitCommit" => git_commit_sha,
472
+ "gitDirty" => git_dirty?,
473
+ "createdAt" => Time.now.utc.iso8601,
474
+ "artifacts" => {
475
+ "ipa" => {
476
+ "path" => Pathname.new(ios_ipa_path).relative_path_from(Pathname.new(repo_root)).to_s,
477
+ "sha256" => file_sha256(ios_ipa_path),
478
+ "bytes" => size
479
+ }
480
+ }
481
+ }
482
+ when :android
483
+ size = assert_artifact!(android_aab_path, "Android AAB", config.dig("build", "maxAabSizeMb"))
484
+ payload = {
485
+ "platform" => "android",
486
+ "version" => current_marketing_version,
487
+ "androidBuild" => current_android_build.to_i,
488
+ "packageName" => config.dig("app", "packageName"),
489
+ "gitCommit" => git_commit_sha,
490
+ "gitDirty" => git_dirty?,
491
+ "createdAt" => Time.now.utc.iso8601,
492
+ "artifacts" => {
493
+ "aab" => {
494
+ "path" => Pathname.new(android_aab_path).relative_path_from(Pathname.new(repo_root)).to_s,
495
+ "sha256" => file_sha256(android_aab_path),
496
+ "bytes" => size
497
+ }
498
+ }
499
+ }
500
+ else
501
+ UI.user_error!("Unknown platform #{platform}")
502
+ end
503
+ FileUtils.mkdir_p(File.dirname(manifest_path(platform)))
504
+ File.write(manifest_path(platform), JSON.pretty_generate(payload))
505
+ UI.success("Wrote #{manifest_path(platform)}")
506
+ payload
507
+ end
508
+
509
+ def write_manifest!
510
+ ios = write_platform_manifest!(:ios)
511
+ android = write_platform_manifest!(:android)
512
+ payload = {
513
+ "version" => current_marketing_version,
514
+ "androidBuild" => current_android_build.to_i,
515
+ "iosBuild" => current_ios_build.to_i,
516
+ "packageName" => config.dig("app", "packageName"),
517
+ "bundleIdentifier" => config.dig("app", "bundleIdentifier"),
518
+ "gitCommit" => git_commit_sha,
519
+ "gitDirty" => git_dirty?,
520
+ "createdAt" => Time.now.utc.iso8601,
521
+ "artifacts" => {
522
+ "ipa" => ios.dig("artifacts", "ipa"),
523
+ "aab" => android.dig("artifacts", "aab")
524
+ }
525
+ }
526
+ File.write(manifest_path, JSON.pretty_generate(payload))
527
+ UI.success("Wrote #{manifest_path}")
528
+ payload
529
+ end
530
+
531
+ def read_manifest!(platform = nil)
532
+ path = manifest_path(platform)
533
+ UI.user_error!("Missing release manifest at #{path}. Run compile/build first.") unless File.exist?(path)
534
+ JSON.parse(File.read(path))
535
+ end
536
+
537
+ def assert_manifest_artifacts!(platform = nil)
538
+ if config.dig("gates", "requireCompileBeforeUpload") != false
539
+ UI.user_error!("Missing combined release manifest. Run compile first.") unless File.exist?(manifest_path) || platform
540
+ end
541
+
542
+ if platform.nil?
543
+ manifest = read_manifest!
544
+ ipa = File.join(repo_root, manifest.dig("artifacts", "ipa", "path"))
545
+ aab = File.join(repo_root, manifest.dig("artifacts", "aab", "path"))
546
+ UI.user_error!("IPA missing for upload: #{ipa}") unless File.exist?(ipa)
547
+ UI.user_error!("AAB missing for upload: #{aab}") unless File.exist?(aab)
548
+ UI.user_error!("IPA checksum mismatch. Re-run compile before upload.") if file_sha256(ipa) != manifest.dig("artifacts", "ipa", "sha256")
549
+ UI.user_error!("AAB checksum mismatch. Re-run compile before upload.") if file_sha256(aab) != manifest.dig("artifacts", "aab", "sha256")
550
+ UI.user_error!("Manifest version mismatch") if manifest["version"] != current_marketing_version
551
+ return manifest
552
+ end
553
+
554
+ manifest = read_manifest!(platform)
555
+ if platform == :ios
556
+ ipa = File.join(repo_root, manifest.dig("artifacts", "ipa", "path"))
557
+ UI.user_error!("IPA missing: #{ipa}") unless File.exist?(ipa)
558
+ UI.user_error!("IPA checksum mismatch") if file_sha256(ipa) != manifest.dig("artifacts", "ipa", "sha256")
559
+ else
560
+ aab = File.join(repo_root, manifest.dig("artifacts", "aab", "path"))
561
+ UI.user_error!("AAB missing: #{aab}") unless File.exist?(aab)
562
+ UI.user_error!("AAB checksum mismatch") if file_sha256(aab) != manifest.dig("artifacts", "aab", "sha256")
563
+ end
564
+ UI.user_error!("Manifest version mismatch") if manifest["version"] != current_marketing_version
565
+ manifest
566
+ end
567
+
568
+ def archive_uploaded_version!(platform)
569
+ return unless config.dig("archives", "storeUploadedVersions")
570
+
571
+ dir = File.join(repo_root, config.dig("archives", "directory") || "versions")
572
+ version_dir = File.join(dir, current_marketing_version)
573
+ FileUtils.mkdir_p(version_dir)
574
+ if platform == :ios && File.exist?(ios_ipa_path)
575
+ FileUtils.cp(ios_ipa_path, File.join(version_dir, File.basename(ios_ipa_path)))
576
+ end
577
+ if platform == :android && File.exist?(android_aab_path)
578
+ FileUtils.cp(android_aab_path, File.join(version_dir, File.basename(android_aab_path)))
579
+ end
580
+ meta = {
581
+ "version" => current_marketing_version,
582
+ "platform" => platform.to_s,
583
+ "archivedAt" => Time.now.utc.iso8601
584
+ }
585
+ File.write(File.join(version_dir, "#{platform}-meta.json"), JSON.pretty_generate(meta))
586
+ UI.success("Archived #{platform} artifact under #{version_dir}")
587
+ end
588
+
589
+ def write_upload_journal!(state)
590
+ FileUtils.mkdir_p(File.dirname(upload_journal_path))
591
+ File.write(upload_journal_path, JSON.pretty_generate(state.merge("updatedAt" => Time.now.utc.iso8601)))
592
+ end
593
+
594
+ def compile_all!
595
+ UI.important("Compiling TypeScript + iOS Release IPA + Android Release AAB (no install, no upload)")
596
+ compile_js!
597
+ build_ios_ipa!
598
+ build_android_aab!
599
+ report_artifact_sizes!
600
+ write_manifest!
601
+ UI.success("Release compile succeeded for iOS and Android.")
602
+ rescue StandardError => e
603
+ log_release_error!("compile_all! failed", e)
604
+ raise
605
+ end
606
+
607
+ def upload_ios!(options = {})
608
+ load_asc_api_key
609
+ upload_to_testflight(
610
+ ipa: ios_ipa_path,
611
+ skip_waiting_for_build_processing: true,
612
+ distribute_external: false,
613
+ changelog: options[:changelog]
614
+ )
615
+ archive_uploaded_version!(:ios)
616
+ rescue StandardError => e
617
+ log_release_error!("iOS upload failed", e)
618
+ raise
619
+ end
620
+
621
+ def upload_android!
622
+ upload_to_play_store(
623
+ package_name: config.dig("app", "packageName"),
624
+ track: config.dig("android", "playTrackBeta"),
625
+ json_key: play_json_key_path,
626
+ aab: android_aab_path,
627
+ skip_upload_apk: true,
628
+ skip_upload_metadata: true,
629
+ skip_upload_images: true,
630
+ skip_upload_screenshots: true,
631
+ skip_upload_changelogs: true,
632
+ release_status: config.dig("android", "releaseStatus") || "completed"
633
+ )
634
+ archive_uploaded_version!(:android)
635
+ rescue StandardError => e
636
+ log_release_error!("Android upload failed", e)
637
+ raise
638
+ end
639
+
640
+ def upload_all_resumable!(options = {})
641
+ assert_manifest_artifacts!
642
+ journal = {
643
+ "version" => current_marketing_version,
644
+ "ios" => "pending",
645
+ "android" => "pending"
646
+ }
647
+ write_upload_journal!(journal)
648
+ begin
649
+ upload_ios!(options)
650
+ journal["ios"] = "uploaded"
651
+ write_upload_journal!(journal)
652
+ rescue StandardError => e
653
+ journal["ios"] = "failed"
654
+ write_upload_journal!(journal)
655
+ log_release_error!("iOS upload failed", e)
656
+ raise
657
+ end
658
+ begin
659
+ upload_android!
660
+ journal["android"] = "uploaded"
661
+ write_upload_journal!(journal)
662
+ rescue StandardError => e
663
+ journal["android"] = "failed"
664
+ write_upload_journal!(journal)
665
+ log_release_error!("Android upload failed after iOS success — resume android upload only", e)
666
+ UI.important("Partial upload journal: #{upload_journal_path}")
667
+ raise
668
+ end
669
+ UI.success("Both platforms uploaded.")
670
+ end
671
+
672
+ def confirm_promote!(platform, details)
673
+ UI.important("PRODUCTION PROMOTE CONFIRMATION")
674
+ details.each { |k, v| UI.important(" #{k}: #{v}") }
675
+ unless ENV["CI"] == "true" || ENV["RN_LOCAL_RELEASE_YES"] == "1"
676
+ UI.important("Re-run with RN_LOCAL_RELEASE_YES=1 to confirm non-interactive promote.")
677
+ end
678
+ end
679
+
680
+ def doctor!
681
+ missing = []
682
+ [
683
+ config.dig("ios", "workspace"),
684
+ config.dig("ios", "project"),
685
+ File.join(config.dig("ios", "project"), "project.pbxproj"),
686
+ config.dig("android", "buildGradlePath"),
687
+ config.dig("ios", "exportOptionsPath")
688
+ ].each do |relative|
689
+ missing << relative unless File.exist?(File.join(repo_root, relative))
690
+ end
691
+ UI.user_error!("Doctor failed. Missing paths:\n- #{missing.join("\n- ")}") unless missing.empty?
692
+
693
+ UI.message("App: #{config.dig('app', 'displayName')} (#{config.dig('app', 'packageName')})")
694
+ UI.message("Version: #{current_marketing_version} / Android #{current_android_build} / iOS #{current_ios_build}")
695
+ UI.message("iOS strategy: #{config.dig('ios', 'buildNumberStrategy')}")
696
+ UI.message("Credential provider: #{config.dig('credentials', 'provider')}")
697
+ UI.message("Logging: #{config.dig('logging')}")
698
+ UI.message("Archives: #{config.dig('archives')}")
699
+ UI.success("Doctor checks passed (config + project paths).")
700
+ end