@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.
- package/CHANGELOG.md +33 -0
- package/CREDENTIALS.md +79 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/SECURITY.md +44 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +326 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +229 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +27 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +317 -0
- package/dist/credentials.js.map +1 -0
- package/dist/detect.d.ts +7 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +326 -0
- package/dist/detect.js.map +1 -0
- package/dist/fs.d.ts +15 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +79 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +5 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +338 -0
- package/dist/install.js.map +1 -0
- package/dist/logging.d.ts +15 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +84 -0
- package/dist/logging.js.map +1 -0
- package/dist/patch.d.ts +39 -0
- package/dist/patch.d.ts.map +1 -0
- package/dist/patch.js +352 -0
- package/dist/patch.js.map +1 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +65 -0
- package/dist/paths.js.map +1 -0
- package/dist/process.d.ts +26 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +43 -0
- package/dist/process.js.map +1 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +70 -0
- package/dist/prompt.js.map +1 -0
- package/dist/types.d.ts +153 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/docs/ARCHITECTURE.md +25 -0
- package/docs/MIGRATION.md +28 -0
- package/docs/PUBLISHING.md +128 -0
- package/docs/SUPPORT.md +17 -0
- package/install.mjs +6 -0
- package/package.json +73 -0
- package/schema/release.config.schema.json +209 -0
- package/template/android/keystore.properties.example +7 -0
- package/template/android/proguard-rules.rn-local-release.pro +28 -0
- package/template/docs/LOCAL_RELEASE.md +85 -0
- package/template/fastlane/.env.example +20 -0
- package/template/fastlane/Appfile +10 -0
- package/template/fastlane/Fastfile +203 -0
- package/template/fastlane/lib/rn_local_release.rb +700 -0
- package/template/ios/exportOptions.plist +18 -0
- package/template/scripts/print-android-signing-fingerprints.sh +62 -0
- package/template/secrets/.gitignore +3 -0
- 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
|