react-native-bspatch 4.1.0 → 5.0.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 (81) hide show
  1. package/README.md +84 -27
  2. package/android/build.gradle +25 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +86 -6
  5. package/android/src/main/res/values/bspatch_public_key.xml +5 -0
  6. package/cpp/bsdiff/bspatch_wrapper.c +34 -0
  7. package/cpp/bsdiff/bspatch_wrapper.h +2 -0
  8. package/cpp/bspatch_jni.cpp +18 -0
  9. package/ios/RNBsPatch.mm +142 -1
  10. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  11. package/lib/commonjs/index.js +9 -2
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/ota/OtaProvider.js +165 -35
  14. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  15. package/lib/commonjs/ota/eligibility.js +47 -0
  16. package/lib/commonjs/ota/eligibility.js.map +1 -0
  17. package/lib/commonjs/ota/entrySignature.js +23 -0
  18. package/lib/commonjs/ota/entrySignature.js.map +1 -0
  19. package/lib/commonjs/ota/installId.js +27 -0
  20. package/lib/commonjs/ota/installId.js.map +1 -0
  21. package/lib/commonjs/ota/otaCore.js +96 -21
  22. package/lib/commonjs/ota/otaCore.js.map +1 -1
  23. package/lib/commonjs/ota/storage/{GitHubRawStorage.js → R2Storage.js} +20 -15
  24. package/lib/commonjs/ota/storage/R2Storage.js.map +1 -0
  25. package/lib/commonjs/ota/types.js.map +1 -1
  26. package/lib/module/NativeBsPatch.js.map +1 -1
  27. package/lib/module/index.js +2 -1
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/ota/OtaProvider.js +165 -35
  30. package/lib/module/ota/OtaProvider.js.map +1 -1
  31. package/lib/module/ota/eligibility.js +41 -0
  32. package/lib/module/ota/eligibility.js.map +1 -0
  33. package/lib/module/ota/entrySignature.js +17 -0
  34. package/lib/module/ota/entrySignature.js.map +1 -0
  35. package/lib/module/ota/installId.js +22 -0
  36. package/lib/module/ota/installId.js.map +1 -0
  37. package/lib/module/ota/otaCore.js +96 -21
  38. package/lib/module/ota/otaCore.js.map +1 -1
  39. package/lib/module/ota/storage/{GitHubRawStorage.js → R2Storage.js} +18 -14
  40. package/lib/module/ota/storage/R2Storage.js.map +1 -0
  41. package/lib/module/ota/types.js.map +1 -1
  42. package/lib/typescript/NativeBsPatch.d.ts +28 -2
  43. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  44. package/lib/typescript/index.d.ts +3 -1
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  47. package/lib/typescript/ota/eligibility.d.ts +25 -0
  48. package/lib/typescript/ota/eligibility.d.ts.map +1 -0
  49. package/lib/typescript/ota/entrySignature.d.ts +9 -0
  50. package/lib/typescript/ota/entrySignature.d.ts.map +1 -0
  51. package/lib/typescript/ota/installId.d.ts +3 -0
  52. package/lib/typescript/ota/installId.d.ts.map +1 -0
  53. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  54. package/lib/typescript/ota/storage/{GitHubRawStorage.d.ts → R2Storage.d.ts} +4 -3
  55. package/lib/typescript/ota/storage/R2Storage.d.ts.map +1 -0
  56. package/lib/typescript/ota/types.d.ts +58 -5
  57. package/lib/typescript/ota/types.d.ts.map +1 -1
  58. package/package.json +3 -2
  59. package/react-native-bspatch.podspec +3 -1
  60. package/src/NativeBsPatch.ts +34 -2
  61. package/src/index.ts +5 -1
  62. package/src/ota/OtaProvider.tsx +167 -42
  63. package/src/ota/eligibility.ts +57 -0
  64. package/src/ota/entrySignature.ts +31 -0
  65. package/src/ota/installId.ts +18 -0
  66. package/src/ota/otaCore.ts +102 -21
  67. package/src/ota/storage/{GitHubRawStorage.ts → R2Storage.ts} +17 -13
  68. package/src/ota/types.ts +55 -5
  69. package/tools/build_ota.sh +343 -116
  70. package/tools/doctor.js +130 -0
  71. package/tools/ensure_signing_key.js +216 -0
  72. package/tools/fingerprint.js +111 -0
  73. package/tools/ios_fingerprint.sh +9 -0
  74. package/tools/ota_admin.js +131 -0
  75. package/tools/setup_project.js +41 -15
  76. package/tools/update_registry.js +111 -11
  77. package/tools/upload_ota.sh +85 -184
  78. package/tools/verify_publish.js +81 -0
  79. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubRawStorage.js.map +0 -1
  81. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +0 -1
@@ -31,19 +31,25 @@ function findFile(baseDir, predicate, maxDepth = 10) {
31
31
  return results;
32
32
  }
33
33
 
34
+ // --quiet (run by every `patch`): print only what changed, one line each
35
+ const QUIET = process.argv.includes('--quiet');
36
+ const log = (...args) => {
37
+ if (!QUIET) console.log(...args);
38
+ };
39
+
34
40
  function patchFile(filePath, label, patchFn) {
35
41
  if (!fs.existsSync(filePath)) {
36
- console.log(` ⚠ ${label}: file not found at ${filePath}`);
42
+ log(` ⚠ ${label}: file not found at ${filePath}`);
37
43
  return false;
38
44
  }
39
45
  const original = fs.readFileSync(filePath, 'utf8');
40
46
  const patched = patchFn(original);
41
47
  if (patched === original) {
42
- console.log(` ✓ ${label}: already set up (skipped)`);
48
+ log(` ✓ ${label}: already set up (skipped)`);
43
49
  return false;
44
50
  }
45
51
  fs.writeFileSync(filePath, patched, 'utf8');
46
- console.log(` ✓ ${label}: patched`);
52
+ console.log(QUIET ? label : ` ✓ ${label}: patched`);
47
53
  return true;
48
54
  }
49
55
 
@@ -103,6 +109,25 @@ function removePbxprojSnippet(content) {
103
109
  return content.slice(0, idx) + content.slice(content.indexOf('";', idx));
104
110
  }
105
111
 
112
+ // Runs tools/ios_fingerprint.sh at the end of RN's "Bundle React Native code and images"
113
+ // phase (after Copy Resources, with NODE_BINARY from .xcode.env): stamps the native
114
+ // runtime fingerprint into the .app so OTAs only install on a matching binary.
115
+ const FINGERPRINT_MARKER = '# [react-native-bspatch] runtime fingerprint';
116
+ const FINGERPRINT_STEP =
117
+ `\\n${FINGERPRINT_MARKER}\\n` +
118
+ '/bin/sh \\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\\" ' +
119
+ '\\"$REACT_NATIVE_PATH/../react-native-bspatch/tools/ios_fingerprint.sh\\"\\n';
120
+
121
+ function addPbxprojFingerprint(content) {
122
+ if (content.includes(FINGERPRINT_MARKER)) return content;
123
+ const phase = content.indexOf('/* Bundle React Native code and images */ = {');
124
+ if (phase === -1) return content;
125
+ const script = content.indexOf('shellScript = "', phase);
126
+ const end = content.indexOf('";', script);
127
+ if (script === -1 || end === -1) return content;
128
+ return content.slice(0, end) + FINGERPRINT_STEP + content.slice(end);
129
+ }
130
+
106
131
  // ── iOS: AppDelegate.swift ────────────────────────────────────────────────────
107
132
 
108
133
  function patchAppDelegate(content) {
@@ -209,28 +234,29 @@ function main() {
209
234
  const rootIdx = args.indexOf('--root');
210
235
  const ROOT = rootIdx !== -1 ? path.resolve(args[rootIdx + 1]) : process.cwd();
211
236
 
212
- console.log('\n=== react-native-bspatch Setup ===');
213
- console.log(` Project root: ${ROOT}`);
237
+ log('\n=== react-native-bspatch Setup ===');
238
+ log(` Project root: ${ROOT}`);
214
239
 
215
240
  const IOS_DIR = path.join(ROOT, 'ios');
216
241
  const ANDROID_DIR = path.join(ROOT, 'android');
217
242
 
218
243
  // ── iOS ──
219
- console.log('\n[iOS]');
244
+ log('\n[iOS]');
220
245
 
221
246
  // project.pbxproj (ignore CocoaPods project file)
222
247
  const pbxprojFiles = findFile(IOS_DIR, (name, fullPath) => name === 'project.pbxproj' && !fullPath.includes('Pods'));
223
248
  if (pbxprojFiles.length === 0) {
224
- console.log(' ⚠ No project.pbxproj found. Is this an iOS project?');
249
+ log(' ⚠ No project.pbxproj found. Is this an iOS project?');
225
250
  } else {
226
251
  const pbxproj = pbxprojFiles[0];
227
252
  patchFile(pbxproj, 'project.pbxproj (remove legacy bundle capture)', removePbxprojSnippet);
253
+ patchFile(pbxproj, 'project.pbxproj (runtime fingerprint build step)', addPbxprojFingerprint);
228
254
  }
229
255
 
230
256
  // AppDelegate.swift
231
257
  const appDelegateFiles = findFile(IOS_DIR, (name) => name === 'AppDelegate.swift');
232
258
  if (appDelegateFiles.length === 0) {
233
- console.log(' ⚠ No AppDelegate.swift found.');
259
+ log(' ⚠ No AppDelegate.swift found.');
234
260
  } else {
235
261
  patchFile(appDelegateFiles[0], 'AppDelegate.swift', patchAppDelegate);
236
262
  }
@@ -239,21 +265,21 @@ function main() {
239
265
  const outputDir = path.join(IOS_DIR, 'output');
240
266
  if (!fs.existsSync(outputDir)) {
241
267
  fs.mkdirSync(outputDir, { recursive: true });
242
- console.log(' ✓ Created ios/output/ directory');
268
+ log(' ✓ Created ios/output/ directory');
243
269
  }
244
270
 
245
271
  // ── Android ──
246
- console.log('\n[Android]');
272
+ log('\n[Android]');
247
273
  const mainAppFiles = findFile(ANDROID_DIR, (name) => name === 'MainApplication.kt');
248
274
  if (mainAppFiles.length === 0) {
249
- console.log(' ⚠ No MainApplication.kt found. Is this an Android project?');
275
+ log(' ⚠ No MainApplication.kt found. Is this an Android project?');
250
276
  } else {
251
277
  patchFile(mainAppFiles[0], 'MainApplication.kt', patchMainApplication);
252
278
  }
253
279
 
254
280
  const appGradleFiles = findFile(path.join(ANDROID_DIR, 'app'), (name) => name === 'build.gradle');
255
281
  if (appGradleFiles.length === 0) {
256
- console.log(' ⚠ No android/app/build.gradle found.');
282
+ log(' ⚠ No android/app/build.gradle found.');
257
283
  } else {
258
284
  patchFile(appGradleFiles[0], 'android/app/build.gradle (remove legacy bundle capture)', removeAppBuildGradleSnippet);
259
285
  }
@@ -262,11 +288,11 @@ function main() {
262
288
  const androidOutputDir = path.join(ANDROID_DIR, 'output');
263
289
  if (!fs.existsSync(androidOutputDir)) {
264
290
  fs.mkdirSync(androidOutputDir, { recursive: true });
265
- console.log(' ✓ Created android/output/ directory');
291
+ log(' ✓ Created android/output/ directory');
266
292
  }
267
293
 
268
- console.log('\n=== Setup Complete ===');
269
- console.log('Next: release the app to the store (v1), then publish OTA updates with: npx react-native-bspatch\n');
294
+ log('\n=== Setup Complete ===');
295
+ log('Next: release the app to the store (v1), then publish OTA updates with: npx react-native-bspatch\n');
270
296
  }
271
297
 
272
298
  main();
@@ -7,11 +7,38 @@
7
7
  * 2. Scopes to : appVersion (e.g. "1.0.0")
8
8
  * 3. Scopes to : flavor (e.g. "dev", "staging", "production")
9
9
  * 4. Writes the full-bundle entry + patch entries and latestOtaVersion for that app+flavor.
10
+ * 5. Writes next to the bundle: its gzip copy (uploaded Content-Encoded) and
11
+ * release_<app>_v<N>_<platform>_<flavor>.json, the version's details for a dashboard.
10
12
  */
11
13
 
12
14
  const fs = require('fs');
13
15
  const path = require('path');
14
16
  const crypto = require('crypto');
17
+ const zlib = require('zlib');
18
+ const { execFileSync } = require('child_process');
19
+
20
+ /** What entrySignature signs. Must stay byte-for-byte equal to src/ota/entrySignature.ts */
21
+ const entryPayload = e =>
22
+ [
23
+ 'bspatch-entry-v2',
24
+ e.appVersion,
25
+ e.flavor ?? '',
26
+ e.platform ?? '',
27
+ e.fromVersion,
28
+ e.toVersion,
29
+ e.bundleHash ?? '',
30
+ e.assetManifestHash ?? '',
31
+ e.minAppVersion ?? '',
32
+ e.runtimeFingerprint ?? '',
33
+ e.hermesBytecodeVersion ?? '',
34
+ ].join('\n');
35
+
36
+ /** Adds KEY=value lines to build_ota.sh's report file (sourced for the summary) */
37
+ const report = (key, value) => {
38
+ if (process.env.BSPATCH_REPORT) {
39
+ fs.appendFileSync(process.env.BSPATCH_REPORT, `${key}='${String(value).replace(/'/g, `'\\''`)}'\n`);
40
+ }
41
+ };
15
42
 
16
43
  const [
17
44
  projectRoot,
@@ -86,6 +113,8 @@ if (!signingKey) {
86
113
  const fullBundleName = `bundle_${appVersion}_v${toOtaVersion}_${platform}_${flavor}.hbc`;
87
114
  const manifestName = path.basename(_assetManifestFile);
88
115
 
116
+ const signBytes = (bytes) =>
117
+ signingKey ? crypto.createSign('SHA256').update(bytes).sign(signingKey, 'base64') : undefined;
89
118
  const sign = (file) => {
90
119
  if (!signingKey) return undefined;
91
120
  const filePath = path.join(outputDir, file);
@@ -93,21 +122,33 @@ if (!signingKey) {
93
122
  console.error(` ❌ Cannot sign ${file}: not found at ${filePath}`);
94
123
  process.exit(1);
95
124
  }
96
- const signer = crypto.createSign('SHA256');
97
- signer.update(fs.readFileSync(filePath));
98
- return signer.sign(signingKey, 'base64');
125
+ return signBytes(fs.readFileSync(filePath));
99
126
  };
127
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
100
128
 
101
129
  // Signature of the resulting bundle: verified by the client on BOTH the patch
102
130
  // path and the full-bundle fallback, so a tampered registry can't swap bundles.
103
131
  const bundleSignature = sign(fullBundleName);
104
132
 
105
- const bundleHash = crypto
106
- .createHash('sha256')
107
- .update(fs.readFileSync(path.join(outputDir, fullBundleName)))
108
- .digest('hex');
133
+ const bundleBytes = fs.readFileSync(path.join(outputDir, fullBundleName));
134
+ const bundleHash = sha256(bundleBytes);
135
+ const manifestBytes = fs.readFileSync(_assetManifestFile);
136
+ const assetManifestHash = sha256(manifestBytes);
137
+ // Hermes bytecode header: magic (8 bytes) then the bytecode version (uint32 LE)
138
+ const hermesBytecodeVersion = bundleBytes.length >= 12 ? bundleBytes.readUInt32LE(8) : undefined;
139
+ // Same inputs the Xcode/Gradle build hook stamps into the store binary; promote/rollback
140
+ // pass the source version's own fingerprint (that bundle was built from another commit)
141
+ const runtimeFingerprint =
142
+ process.env.BSPATCH_FINGERPRINT ||
143
+ execFileSync('node', [path.join(__dirname, 'fingerprint.js'), process.cwd(), platform]).toString().trim();
144
+ const previous = flavorGroup.patches.find(p => p.platform === platform);
145
+ if (previous?.runtimeFingerprint && previous.runtimeFingerprint !== runtimeFingerprint) {
146
+ // Not an error: a new store build of this release takes it. Installed binaries won't.
147
+ report('NATIVE_CHANGED', `v${previous.toVersion}`);
148
+ }
109
149
 
110
- const entry = (from, extra) => ({
150
+ // Signed last: entryPayload reads the final fields
151
+ const entry = (from, extra) => signEntry({
111
152
  id: `v${from}-to-v${toOtaVersion}-${flavor}-${platform}`,
112
153
  label: `V${from} → V${toOtaVersion} (${flavor} ${platform})`,
113
154
  appVersion,
@@ -115,13 +156,22 @@ if (!signingKey) {
115
156
  toVersion: toOtaVersion,
116
157
  platform,
117
158
  bundleHash,
118
- fullBundleUrl: fullBundleName,
159
+ // Uploaded as a plain .gz (no Content-Encoding): resumable, with an exact progress total
160
+ fullBundleUrl: `${fullBundleName}.gz`,
161
+ fullBundleCompression: 'gzip',
162
+ requires: ['gzip'],
163
+ bundleSize: bundleBytes.length,
164
+ runtimeFingerprint,
165
+ hermesBytecodeVersion,
119
166
  assetManifestUrl: manifestName,
167
+ assetManifestHash,
120
168
  flavor,
121
169
  bundleSignature,
122
170
  ...extra,
123
171
  });
124
172
 
173
+ const signEntry = (e) => ({ ...e, entrySignature: signBytes(entryPayload(e)) });
174
+
125
175
  // fromVersion 1 = the store build, whose clients always take the full bundle
126
176
  const fullEntry = entry(1, {
127
177
  id: `full-to-v${toOtaVersion}-${flavor}-${platform}`,
@@ -132,14 +182,64 @@ if (!signingKey) {
132
182
  );
133
183
 
134
184
  flavorGroup.patches = [...otherPlatformPatches, fullEntry, ...patchEntries];
185
+
186
+ const gzipBytes = zlib.gzipSync(bundleBytes, { level: 9 });
187
+ fs.writeFileSync(path.join(outputDir, `${fullBundleName}.gz`), gzipBytes);
188
+
189
+ // ── Release details (public, next to the bundle; clients never read it) ──
190
+ const git = (...args) => {
191
+ try {
192
+ return execFileSync('git', args, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
193
+ } catch {
194
+ return null;
195
+ }
196
+ };
197
+ const commit = git('rev-parse', 'HEAD');
198
+ const release = {
199
+ schema: 1,
200
+ appVersion,
201
+ flavor,
202
+ platform,
203
+ version: toOtaVersion,
204
+ publishedAt: new Date().toISOString(),
205
+ publishedBy: git('config', 'user.name') || process.env.USER || null,
206
+ notes: process.env.BSPATCH_NOTES || null,
207
+ git: commit && {
208
+ commit,
209
+ branch: git('rev-parse', '--abbrev-ref', 'HEAD'),
210
+ // Tracked files only: build output is often untracked
211
+ dirty: !!git('status', '--porcelain', '--untracked-files=no'),
212
+ },
213
+ cliVersion: require('../package.json').version,
214
+ bundle: { file: fullBundleName, hash: bundleHash, size: bundleBytes.length, gzipSize: gzipBytes.length },
215
+ runtimeFingerprint,
216
+ hermesBytecodeVersion,
217
+ patches: newPatches.map(p => ({
218
+ from: p.from,
219
+ file: p.file,
220
+ size: fs.statSync(path.join(outputDir, p.file)).size,
221
+ })),
222
+ assets: {
223
+ manifest: manifestName,
224
+ hash: assetManifestHash,
225
+ count: (JSON.parse(manifestBytes).assets || []).length,
226
+ },
227
+ signing: signingKey && {
228
+ keyId: sha256(crypto.createPublicKey(signingKey).export({ type: 'spki', format: 'der' })).slice(0, 16),
229
+ },
230
+ };
231
+ fs.writeFileSync(
232
+ path.join(outputDir, `release_${appVersion}_v${toOtaVersion}_${platform}_${flavor}.json`),
233
+ JSON.stringify(release, null, 2) + '\n',
234
+ );
135
235
  }
136
236
 
137
237
  // ── Write updated registry ───────────────────────────────────────────────────
138
238
  fs.mkdirSync(path.dirname(registryPath), { recursive: true });
139
239
  fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
140
- // Same layout as build_ota.sh step details
240
+ // "<entries> · <signing>": build_ota.sh shows it as the Registry step's result
141
241
  const patchCount = newPatches.length;
142
242
  console.log(
143
- ` ${'entries'.padEnd(18)} full bundle${patchCount ? ` + ${patchCount} patch${patchCount > 1 ? 'es' : ''}` : ''}` +
243
+ `full bundle${patchCount ? ` + ${patchCount} patch${patchCount > 1 ? 'es' : ''}` : ''}` +
144
244
  (signingKey ? ' · signed' : ' · unsigned (no ota_private_key.pem)'),
145
245
  );
@@ -1,23 +1,27 @@
1
1
  #!/bin/bash
2
2
 
3
3
  # ─────────────────────────────────────────────────────────────────────────────
4
- # OTA Upload Script — Cloudflare R2 (when OTA_R2_BUCKET is set) or a GitHub storage repository
5
- # Uploads: 1) All .patch & .hbc files → {baseFolder}/{appVersion}/{channel}/
6
- # 2) ota_registry.json → {baseFolder}/ota_registry.json
4
+ # OTA Upload Script — Cloudflare R2
5
+ # Uploads: 1) This version's .hbc.gz, .patch, .json → {baseFolder}/{appVersion}/{channel}/
6
+ # 2) New content-addressed assets → {baseFolder}/assets/
7
+ # 3) ota_registry.json, last → {baseFolder}/ota_registry.json
7
8
  # ─────────────────────────────────────────────────────────────────────────────
8
9
 
9
- # Env (.env.<flavor> + .env.ota) is loaded and exported by build_ota.sh
10
+ # Called by build_ota.sh inside its Upload/Remote step: env (.env.<flavor> + .env.ota)
11
+ # and the step_progress/step_fail/run_logged helpers are exported from there
10
12
  ROOT_DIR=$(pwd)
11
13
 
12
- BASE_URL="${OTA_BASE_URL:-}"
13
14
  BASE_FOLDER="${OTA_FOLDER:-ota}"
14
15
  APP_VERSION="$1"
15
16
  CHANNEL="$2"
16
17
  OTA_VERSION="$3"
17
- BUNDLE_PATH="$4" # unused — we upload .patch files, not the bundle
18
+ # $4 (bundle path) is unused: this version's files are picked up from the output dir
18
19
  PLATFORM="$5"
19
20
 
20
21
  REGISTRY_FILE="$ROOT_DIR/ota_registry.json"
22
+ WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
23
+ # Result for build_ota.sh's step line and summary (sourced there)
24
+ report() { printf '%s=%q\n' "$1" "$2" >> "${BSPATCH_REPORT:-/dev/null}"; }
21
25
  OUTPUT_DIR="$ROOT_DIR/android/output"
22
26
  if [[ "$PLATFORM" == "ios" ]]; then
23
27
  OUTPUT_DIR="$ROOT_DIR/ios/output"
@@ -49,187 +53,84 @@ is_release_file() {
49
53
  }
50
54
 
51
55
  # ── Cloudflare R2 (S3 API, signed by curl --aws-sigv4; no CLI needed) ────────
52
- if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
53
- : "${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}" "${OTA_R2_ACCESS_KEY_ID:?set OTA_R2_ACCESS_KEY_ID}" "${OTA_R2_SECRET_ACCESS_KEY:?set OTA_R2_SECRET_ACCESS_KEY}"
54
- R2_URL="https://${OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
55
- r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" "$@"; }
56
- # "patch_1.0.4_v4_to_v7_…" → "patch v4 → v7", "bundle_…_v7_…" → "bundle v7"
57
- pretty() {
58
- case "$1" in
59
- patch_*) echo "$1" | sed -E 's/.*_v([0-9]+)_to_v([0-9]+)_.*/patch v\1 → v\2/' ;;
60
- bundle_*) echo "$1" | sed -E 's/.*_v([0-9]+)_.*/bundle v\1/' ;;
61
- asset-manifest_*) echo "asset manifest" ;;
62
- *) echo "$1" ;;
63
- esac
64
- }
65
- r2_put() { # <file> <key> <cache-control> <label>
66
- local SIZE PROGRESS=(-s) BAR=false
67
- SIZE=$(wc -c < "$1" | tr -d ' ')
68
- # Progress bar for big files (bundles) in a terminal; erased once done
69
- if [ -t 1 ] && [ "$SIZE" -gt 1048576 ]; then printf " ↑ %s\n" "$4"; PROGRESS=(-#); BAR=true; fi
70
- COLUMNS=60 curl -S "${PROGRESS[@]}" -f --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" \
71
- -X PUT -T "$1" -H "Cache-Control: $3" "$R2_URL/$2" -o /dev/null || { echo " ❌ Upload failed: $2"; exit 1; }
72
- if [ "$BAR" = true ]; then printf "\033[2A\033[K\n\033[K\033[1A"; fi
73
- printf " ${GREEN}✓${RESET} %s %s\n" "$(padr "$4" 16)" "$(human_size "$SIZE")"
74
- }
75
- RELEASE_PREFIX="$BASE_FOLDER/$APP_VERSION/$CHANNEL"
76
- IMMUTABLE="public, max-age=31536000, immutable"
77
-
78
- if [ "$CLEAN_REMOTE" = true ]; then
79
- # ponytail: one ListObjectsV2 page (1000 keys); a release+channel holds far fewer
80
- LISTING=$(r2 -f "$R2_URL?list-type=2&prefix=$RELEASE_PREFIX/") || { echo " ❌ Listing $RELEASE_PREFIX/ failed"; exit 1; }
81
- DELETED=0; BUNDLES=0; PATCHES=0; MANIFESTS=0
82
- for KEY in $(echo "$LISTING" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g'); do
83
- r2 -f -X DELETE "$R2_URL/$KEY" -o /dev/null || { echo " ❌ Delete failed: $KEY"; exit 1; }
84
- DELETED=$((DELETED + 1))
85
- case "$KEY" in *.hbc) BUNDLES=$((BUNDLES + 1)) ;; *.patch) PATCHES=$((PATCHES + 1)) ;; *.json) MANIFESTS=$((MANIFESTS + 1)) ;; esac
86
- if [ -t 1 ]; then printf "\r\033[K ${DIM}%-9s${RESET} deleting… %d" "remote" "$DELETED"; fi
87
- done
88
- if [ -t 1 ]; then printf "\r\033[K"; fi
89
- info remote "$DELETED object(s) deleted ${DIM}($BUNDLES bundles · $PATCHES patches · $MANIFESTS manifests)${RESET}"
90
- else
91
- # Only this version's files: earlier bundles/patches are already uploaded and never change
92
- RELEASE_FILES=()
93
- for FILE in "$OUTPUT_DIR"/*_v"${OTA_VERSION}"_*.{patch,hbc,json}; do
94
- [[ -f "$FILE" ]] && is_release_file "$(basename "$FILE")" && RELEASE_FILES+=("$FILE")
95
- done
96
- for FILE in "${RELEASE_FILES[@]}"; do
97
- FNAME=$(basename "$FILE")
98
- r2_put "$FILE" "$RELEASE_PREFIX/$FNAME" "$IMMUTABLE" "$(pretty "$FNAME")"
99
- done
100
- # Content-addressed assets: upload only hashes the bucket doesn't have yet
101
- ASSETS=("$OUTPUT_DIR"/asset-store/*)
102
- [[ -f "${ASSETS[0]}" ]] || ASSETS=()
103
- N=0; NEW=0; NEW_BYTES=0
104
- for FILE in "${ASSETS[@]}"; do
105
- N=$((N + 1))
106
- KEY="$BASE_FOLDER/assets/$(basename "$FILE")"
107
- if ! r2 -f -I "$R2_URL/$KEY" -o /dev/null 2>/dev/null; then
108
- r2 -f -X PUT -T "$FILE" -H "Cache-Control: $IMMUTABLE" "$R2_URL/$KEY" -o /dev/null || { echo " ❌ Upload failed: $KEY"; exit 1; }
109
- NEW=$((NEW + 1)); NEW_BYTES=$((NEW_BYTES + $(wc -c < "$FILE")))
110
- fi
111
- if [ -t 1 ]; then printf "\r\033[K ${DIM}assets %d/%d checked${RESET}" "$N" "${#ASSETS[@]}"; fi
112
- done
113
- if [ -t 1 ] && [ ${#ASSETS[@]} -gt 0 ]; then printf "\r\033[K"; fi
114
- printf " ${GREEN}✓${RESET} %s %s\n" "$(padr assets 16)" "${#ASSETS[@]} · $NEW new ($(human_size "$NEW_BYTES"))"
115
- fi
56
+ : "${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}" "${OTA_R2_ACCESS_KEY_ID:?set OTA_R2_ACCESS_KEY_ID}" "${OTA_R2_SECRET_ACCESS_KEY:?set OTA_R2_SECRET_ACCESS_KEY}"
57
+ R2_URL="https://${OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
58
+ r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "$OTA_R2_ACCESS_KEY_ID:$OTA_R2_SECRET_ACCESS_KEY" "$@"; }
59
+ # "patch_1.0.4_v4_to_v7_…" → "patch v4 → v7", "bundle_…_v7_…" → "bundle v7"
60
+ pretty() {
61
+ case "$1" in
62
+ patch_*) echo "$1" | sed -E 's/.*_v([0-9]+)_to_v([0-9]+)_.*/patch v\1 → v\2/' ;;
63
+ bundle_*) echo "$1" | sed -E 's/.*_v([0-9]+)_.*/bundle v\1/' ;;
64
+ asset-manifest_*) echo "asset manifest" ;;
65
+ release_*) echo "release info" ;;
66
+ *) echo "$1" ;;
67
+ esac
68
+ }
69
+ TOTAL_BYTES=0
70
+ r2_put() { # <file> <key> <cache-control> <label>
71
+ local SIZE; SIZE=$(file_size "$1")
72
+ RUN_NOTE="↑ $4 · $(human_size "$SIZE")" run_logged "$WORK/curl.log" \
73
+ r2 -f -X PUT -T "$1" -H "Cache-Control: $3" "$R2_URL/$2" -o /dev/null \
74
+ || { step_fail "upload failed: $2"; cat "$WORK/curl.log"; exit 1; }
75
+ TOTAL_BYTES=$((TOTAL_BYTES + SIZE))
76
+ }
77
+ RELEASE_PREFIX="$BASE_FOLDER/$APP_VERSION/$CHANNEL"
78
+ IMMUTABLE="public, max-age=31536000, immutable"
79
+
80
+ if [ "$CLEAN_REMOTE" = true ]; then
81
+ # ponytail: one ListObjectsV2 page (1000 keys); a release+channel holds far fewer
82
+ LISTING=$(r2 -f "$R2_URL?list-type=2&prefix=$RELEASE_PREFIX/") || { step_fail "listing $RELEASE_PREFIX/ failed"; exit 1; }
83
+ DELETED=0; BUNDLES=0; PATCHES=0; MANIFESTS=0
84
+ for KEY in $(echo "$LISTING" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g'); do
85
+ r2 -f -X DELETE "$R2_URL/$KEY" -o /dev/null || { step_fail "delete failed: $KEY"; exit 1; }
86
+ DELETED=$((DELETED + 1))
87
+ case "$KEY" in *.hbc) BUNDLES=$((BUNDLES + 1)) ;; *.patch) PATCHES=$((PATCHES + 1)) ;; *.json) MANIFESTS=$((MANIFESTS + 1)) ;; esac
88
+ step_progress "deleting… $DELETED"
89
+ done
116
90
  # Registry last: clients never see a version whose files aren't uploaded yet
117
- if [ "$CLEAN_REMOTE" = true ]; then
118
- r2 -f -X PUT -T "$REGISTRY_FILE" -H "Cache-Control: no-cache" "$R2_URL/$BASE_FOLDER/ota_registry.json" -o /dev/null || { echo " ❌ Upload failed: ota_registry.json"; exit 1; }
119
- info registry "uploaded"
120
- else
121
- r2_put "$REGISTRY_FILE" "$BASE_FOLDER/ota_registry.json" "no-cache" "registry"
122
- fi
91
+ r2 -f -X PUT -T "$REGISTRY_FILE" -H "Cache-Control: no-cache" "$R2_URL/$BASE_FOLDER/ota_registry.json" -o /dev/null || { step_fail "upload failed: ota_registry.json"; exit 1; }
92
+ report UP_SIZE "$DELETED deleted"
93
+ report UP_NOTE "$BUNDLES bundles · $PATCHES patches · $MANIFESTS manifests · registry updated"
123
94
  exit 0
124
95
  fi
125
96
 
126
- # ── GitHub Auto-Push Integration ──────────────────────────────────────────────
127
- if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
128
- echo "ERROR: OTA_BASE_URL must be https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
129
- exit 1
130
- fi
131
-
132
- # Parse OWNER, REPO, BRANCH from URL
133
- # Example: https://raw.githubusercontent.com/hungbv-taureau/baseform-storage/main
134
- CLEAN_URL="${BASE_URL#https://raw.githubusercontent.com/}"
135
- OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
136
- REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
137
- BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
138
- if [[ -z "$BRANCH" ]]; then BRANCH="main"; fi
139
-
140
- # Check for gh token or env tokens
141
- GH_CLI_TOKEN=$(gh auth token 2>/dev/null || true)
142
- TOKEN="${GH_CLI_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
143
- if [[ "$TOKEN" == "baseform" ]]; then TOKEN=""; fi
144
-
145
- # Token goes in a per-command header, never into the clone's .git/config
146
- GIT_AUTH=()
147
- if [[ -n "$TOKEN" ]]; then
148
- GIT_REMOTE="https://github.com/${OWNER}/${REPO}.git"
149
- GIT_AUTH=(-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')")
150
- echo " Using authenticated gh CLI token for ${OWNER}/${REPO}"
151
- else
152
- GIT_REMOTE="git@github.com:${OWNER}/${REPO}.git"
153
- fi
154
-
155
- GH_REPO_DIR="/tmp/ota_git_repo_${REPO}"
156
-
157
- echo ""
158
- echo "=== Auto Syncing OTA Release to GitHub Storage ==="
159
- echo " Target Repo : ${OWNER}/${REPO}"
160
- echo " Branch : ${BRANCH}"
161
-
162
- # 1. Clone or Pull target storage repo
163
- if [ -d "$GH_REPO_DIR/.git" ]; then
164
- echo " Syncing local storage clone with remote..."
165
- git -C "$GH_REPO_DIR" remote set-url origin "$GIT_REMOTE" 2>/dev/null || true
166
- git "${GIT_AUTH[@]}" -C "$GH_REPO_DIR" fetch origin "$BRANCH" 2>/dev/null || true
167
- git -C "$GH_REPO_DIR" checkout "$BRANCH" 2>/dev/null || true
168
- git -C "$GH_REPO_DIR" reset --hard "origin/$BRANCH" 2>/dev/null || true
169
- else
170
- echo " Cloning storage repository..."
171
- rm -rf "$GH_REPO_DIR"
172
- git "${GIT_AUTH[@]}" clone --branch "$BRANCH" --depth 1 "$GIT_REMOTE" "$GH_REPO_DIR" 2>/dev/null || git "${GIT_AUTH[@]}" clone "$GIT_REMOTE" "$GH_REPO_DIR" || exit 1
173
- chmod 700 "$GH_REPO_DIR"
174
- fi
175
-
176
- # 2. Copy generated files into storage repo (or reset remote)
177
- if [ "$CLEAN_REMOTE" = true ]; then
178
- echo " Resetting remote storage state for channel '$CHANNEL' ..."
179
- rm -rf "$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/${CHANNEL:?}"
180
- mkdir -p "$GH_REPO_DIR/$BASE_FOLDER"
181
- cp "$REGISTRY_FILE" "$GH_REPO_DIR/$BASE_FOLDER/ota_registry.json"
182
- cp "$REGISTRY_FILE" "$GH_REPO_DIR/ota_registry.json" 2>/dev/null || true
183
- else
184
- TARGET_RELEASE_DIR="$GH_REPO_DIR/$BASE_FOLDER/$APP_VERSION/$CHANNEL"
185
- mkdir -p "$TARGET_RELEASE_DIR"
186
-
187
- echo " Copying release files to storage repo for channel '$CHANNEL'..."
188
- for FILE in "$OUTPUT_DIR"/*.{patch,hbc,json}; do
189
- [[ -f "$FILE" ]] || continue
190
- FNAME=$(basename "$FILE")
191
- is_release_file "$FNAME" || continue
192
- cp "$FILE" "$TARGET_RELEASE_DIR/$FNAME"
193
- done
194
-
195
- # Copy asset store if present
196
- if [[ -d "$OUTPUT_DIR/asset-store" ]]; then
197
- TARGET_ASSET_DIR="$GH_REPO_DIR/$BASE_FOLDER/assets"
198
- mkdir -p "$TARGET_ASSET_DIR"
199
- cp -R "$OUTPUT_DIR/asset-store/"* "$TARGET_ASSET_DIR/" 2>/dev/null || true
200
- fi
97
+ # Only this version's files: earlier bundles/patches are already uploaded and never change.
98
+ # The bundle ships as its .gz only (written by update_registry.js): clients and later
99
+ # `patch` runs decompress it, so the raw .hbc never needs to be on storage.
100
+ for FILE in "$OUTPUT_DIR"/*_v"${OTA_VERSION}"_*.{patch,gz,json}; do
101
+ [[ -f "$FILE" ]] || continue
102
+ FNAME=$(basename "$FILE")
103
+ is_release_file "$FNAME" || continue
104
+ r2_put "$FILE" "$RELEASE_PREFIX/$FNAME" "$IMMUTABLE" "$(pretty "$FNAME")"
105
+ if [[ "$FNAME" == *.hbc.gz ]]; then report UP_GZIP_BYTES "$(file_size "$FILE")"; fi
106
+ done
201
107
 
202
- # Copy ota_registry.json
203
- mkdir -p "$GH_REPO_DIR/$BASE_FOLDER"
204
- cp "$REGISTRY_FILE" "$GH_REPO_DIR/$BASE_FOLDER/ota_registry.json"
205
- cp "$REGISTRY_FILE" "$GH_REPO_DIR/ota_registry.json" 2>/dev/null || true
206
- fi
108
+ # Content-addressed assets: upload only hashes the bucket doesn't have yet.
109
+ # One listing (1000 keys a page) instead of a HEAD request per asset.
110
+ ASSETS=("$OUTPUT_DIR"/asset-store/*)
111
+ [[ -f "${ASSETS[0]}" ]] || ASSETS=()
112
+ if [ ${#ASSETS[@]} -gt 0 ]; then
113
+ step_progress "checking ${#ASSETS[@]} assets…"
114
+ : > "$WORK/remote-assets"
115
+ AFTER=""
116
+ while :; do
117
+ PAGE=$(r2 -f "$R2_URL?list-type=2&prefix=$BASE_FOLDER/assets/${AFTER:+&start-after=$AFTER}") || { step_fail "listing $BASE_FOLDER/assets/ failed"; exit 1; }
118
+ echo "$PAGE" | grep -o '<Key>[^<]*</Key>' | sed 's/<[^>]*>//g' >> "$WORK/remote-assets"
119
+ [[ "$PAGE" == *"<IsTruncated>true</IsTruncated>"* ]] || break
120
+ AFTER=$(tail -n 1 "$WORK/remote-assets")
121
+ done
122
+ NEW=0; NEW_BYTES=0
123
+ for FILE in "${ASSETS[@]}"; do
124
+ KEY="$BASE_FOLDER/assets/$(basename "$FILE")"
125
+ grep -qxF "$KEY" "$WORK/remote-assets" && continue
126
+ step_progress "↑ asset $((NEW + 1)) · $(basename "$FILE")"
127
+ r2 -f -X PUT -T "$FILE" -H "Cache-Control: $IMMUTABLE" "$R2_URL/$KEY" -o /dev/null || { step_fail "upload failed: $KEY"; exit 1; }
128
+ NEW=$((NEW + 1)); NEW_BYTES=$((NEW_BYTES + $(file_size "$FILE")))
129
+ done
130
+ report UP_ASSETS "$NEW new of ${#ASSETS[@]} · $(human_size "$NEW_BYTES")"; TOTAL_BYTES=$((TOTAL_BYTES + NEW_BYTES))
131
+ fi
207
132
 
208
- # 3. Commit and Push to GitHub
209
- cd "$GH_REPO_DIR"
210
- # Fallback identity only where none is configured (e.g. CI); never override the user's
211
- git config user.name >/dev/null || git config user.name "react-native-bspatch"
212
- git config user.email >/dev/null || git config user.email "react-native-bspatch@users.noreply.github.com"
213
- git add .
214
- if git diff --staged --quiet; then
215
- echo " ✓ Storage repository is up to date (no changes)."
216
- else
217
- COMMIT_MSG="auto(ota): publish $PLATFORM $APP_VERSION v$OTA_VERSION ($CHANNEL)"
218
- if [ "$CLEAN_REMOTE" = true ]; then
219
- COMMIT_MSG="auto(ota): reset remote OTA registry and releases"
220
- fi
221
- echo " Committing OTA changes ($COMMIT_MSG)..."
222
- git commit -m "$COMMIT_MSG"
223
- echo " Pushing to GitHub (${BRANCH})..."
224
-
225
- if git "${GIT_AUTH[@]}" push origin "$BRANCH"; then
226
- echo " ✓ Successfully pushed OTA release to GitHub!"
227
- else
228
- # Usually someone else pushed first: re-run so the registry is re-synced, never force-push
229
- echo " ❌ Git push failed (remote changed or no write access to ${OWNER}/${REPO}). Re-run the build."
230
- exit 1
231
- fi
232
- fi
233
- cd "$ROOT_DIR"
234
- echo ""
235
- echo "=== GitHub Sync Complete ==="
133
+ # Registry last: clients never see a version whose files aren't uploaded yet
134
+ r2_put "$REGISTRY_FILE" "$BASE_FOLDER/ota_registry.json" "no-cache" "registry"
135
+ report UP_SIZE "$(human_size "$TOTAL_BYTES")"
136
+ report UP_DEST "R2 $OTA_R2_BUCKET"