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
@@ -8,10 +8,16 @@
8
8
  # Clients on the store build download the full bundle once, then patch.
9
9
  #
10
10
  # Usage (npx react-native-bspatch ...):
11
- # patch [android|ios] [--flavor staging] [--release-version 1.0.4] [-y]
12
- # → build + publish the next OTA (both platforms if omitted)
11
+ # patch [android|ios] [--flavor staging] [--release-version 1.0.4] [-m "notes"] [-y] [--reset-cache]
12
+ # [--dry-run] [--json] → build + publish the next OTA (both platforms if omitted)
13
+ # promote --from <flavor> [--source-version N] --flavor <target>
14
+ # → publish another flavor's bundle here, no build
15
+ # rollback --to N → publish vN's bundle again as the next version, no build
16
+ # status | doctor → what's live for this release / check the whole setup
17
+ # disable N | enable N | rollout N <percent> | mandatory N [--off] | qa N --ids a,b
18
+ # → release controls on the live version (registry only)
13
19
  # reset [--flavor ...] [--push] → wipe this release's OTAs
14
- # setup → wire OtaBundleResolver into AppDelegate / MainApplication
20
+ # setup → wire OtaBundleResolver + runtime fingerprint into the native project
15
21
  # version <x.y.z> → set the native app version (package.json, Gradle, Xcode)
16
22
  # The release version is read from the native project and confirmed by a prompt (Enter keeps it).
17
23
  # ─────────────────────────────────────────────────────────────────────────────
@@ -48,7 +54,19 @@ while [[ "$#" -gt 0 ]]; do
48
54
  --release-version|--app-version) APP_VERSION_ARG="$2"; shift ;;
49
55
  -y|--yes|--non-interactive) CONFIRM_VERSION=false ;;
50
56
  --push) PUSH_REMOTE=true ;;
57
+ --reset-cache) RESET_CACHE=true ;;
58
+ -m|--message|--notes) export BSPATCH_NOTES="$2"; NOTES_GIVEN=true; shift ;;
59
+ --dry-run) DRY_RUN=true ;;
60
+ --json) JSON_OUT=true ;;
61
+ --from) SOURCE_FLAVOR="$2"; shift ;;
62
+ --source-version|--to) SOURCE_VERSION="$2"; shift ;;
63
+ --ids) ADMIN_IDS="$2"; shift ;;
64
+ --off) ADMIN_OFF=true ;;
51
65
  patch) ACTION="ota" ;;
66
+ promote) ACTION="ota"; PUBLISH_MODE="promote" ;;
67
+ rollback) ACTION="ota"; PUBLISH_MODE="rollback" ;;
68
+ status|doctor|disable|enable|rollout|mandatory|qa) ACTION="admin"; ADMIN_CMD="$1" ;;
69
+ [0-9]*) ADMIN_ARGS+=("$1") ;;
52
70
  android|ios) PLATFORM_ARG="$1" ;;
53
71
  reset|setup) ACTION="$1" ;;
54
72
  base|seed|apk|ipa|--apk|--ipa|*.apk|*.aab|*.ipa)
@@ -60,6 +78,17 @@ while [[ "$#" -gt 0 ]]; do
60
78
  esac
61
79
  shift
62
80
  done
81
+ # promote: another flavor's latest (or --source-version) bundle; rollback: this flavor's vN
82
+ if [ "$PUBLISH_MODE" = "promote" ] && [ -z "$SOURCE_FLAVOR" ]; then
83
+ echo "❌ promote needs --from <flavor> (e.g. promote --from staging --flavor production)"; exit 1
84
+ fi
85
+ if [ "$PUBLISH_MODE" = "rollback" ]; then
86
+ if [ -z "$SOURCE_VERSION" ]; then echo "❌ rollback needs --to <version> (see 'status')"; exit 1; fi
87
+ SOURCE_FLAVOR="$FLAVOR"
88
+ fi
89
+ SOURCE_FLAVOR=$(echo "$SOURCE_FLAVOR" | tr '[:upper:]' '[:lower:]')
90
+ case "$SOURCE_FLAVOR" in dev) SOURCE_FLAVOR="development" ;; prod) SOURCE_FLAVOR="production" ;; esac
91
+
63
92
  # Configuration
64
93
  FLAVOR_LOWER=$(echo "$FLAVOR" | tr '[:upper:]' '[:lower:]')
65
94
  if [ "$FLAVOR_LOWER" = "dev" ]; then FLAVOR_LOWER="development"; fi
@@ -67,19 +96,27 @@ if [ "$FLAVOR_LOWER" = "prod" ]; then FLAVOR_LOWER="production"; fi
67
96
 
68
97
  # ── Output (exported: upload_ota.sh prints in the same layout) ────────────────
69
98
  if [ -t 1 ]; then
70
- BOLD=$'\033[1m'; DIM=$'\033[2m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; RESET=$'\033[0m'
99
+ BOLD=$'\033[1m'; DIM=$'\033[2m'; RED=$'\033[31m'; GREEN=$'\033[38;5;84m'; YELLOW=$'\033[38;5;221m'
100
+ CYAN=$'\033[38;5;81m'; PINK=$'\033[38;5;213m'; RESET=$'\033[0m'
71
101
  else
72
- BOLD=""; DIM=""; GREEN=""; YELLOW=""; RESET=""
102
+ BOLD=""; DIM=""; RED=""; GREEN=""; YELLOW=""; CYAN=""; PINK=""; RESET=""
73
103
  fi
74
104
  # Pad to N characters, not bytes ("→" is 3 bytes, printf %-Ns counts bytes)
75
105
  padr() { local LC_ALL=en_US.UTF-8; printf "%s%*s" "$1" $(( $2 > ${#1} ? $2 - ${#1} : 0 )) ""; }
76
- info() { printf " ${DIM}%s${RESET} %s\n" "$(padr "$1" 9)" "$2"; } # " storage R2 bucket"
77
- sub() { printf " %s %s\n" "$(padr "$1" 18)" "$2"; } # detail under a step
78
- export BOLD DIM GREEN YELLOW RESET
79
- export -f padr info sub
106
+ info() { printf " ${CYAN}%s${RESET} %s\n" "$(padr "$1" 9)" "$2"; } # " storage R2 bucket"
107
+ badge() { # <NAME> → " NAME " on a colored background (plain without a terminal)
108
+ local C
109
+ case "$1" in
110
+ BUILD) C="97;48;5;33" ;; DIFF) C="97;48;5;129" ;; SHIP) C="30;48;5;44" ;; DONE) C="30;48;5;42" ;; FAIL) C="97;48;5;160" ;;
111
+ IOS) C="97;48;5;240" ;; ANDROID) C="30;48;5;113" ;; *) C="97;48;5;57" ;;
112
+ esac
113
+ if [ -n "$RESET" ]; then printf "\033[1;%sm %-5s \033[0m" "$C" "$1"; else printf " %-5s " "$1"; fi
114
+ }
115
+ export BOLD DIM RED GREEN YELLOW CYAN PINK RESET
80
116
  if [ "$ACTION" != "setup" ]; then
81
117
  if [ "$ACTION" = "ota" ]; then TITLE="patch ${PLATFORM_ARG:-android + ios}"; else TITLE="$ACTION"; fi
82
- printf "\n${BOLD}react-native-bspatch${RESET} ${DIM}%s · %s${RESET}\n" "$TITLE" "$FLAVOR_LOWER"
118
+ PKG_VERSION=$(node -p "require(process.argv[1]).version" "$SCRIPT_DIR/../package.json" 2>/dev/null)
119
+ printf "\n%s ${BOLD}%s${RESET} ${DIM}·${RESET} ${PINK}%s${RESET} ${DIM}· v%s${RESET}\n\n" "$(badge BSPATCH)" "$TITLE" "$FLAVOR_LOWER" "$PKG_VERSION"
83
120
  fi
84
121
 
85
122
  # ── Environment ──────────────────────────────────────────────────────────────
@@ -121,19 +158,23 @@ if [ "$ACTION" = "setup" ]; then
121
158
  fi
122
159
 
123
160
  # ── Build environment ──────────────────────────────────────────────────────
124
- if [ "$ACTION" = "ota" ]; then
161
+ if [ "$ACTION" = "ota" ] && [ -z "$SOURCE_FLAVOR" ]; then
125
162
  # Kill any existing Metro processes to prevent file locks
126
163
  pkill -f "react-native/local-cli/cli.js" || true
127
164
  pkill -f "metro-config/src/index.js" || true
128
165
 
129
- # Manually clear metro cache if it exists (robust vs ENOTEMPTY error)
166
+ # A warm cache saves most of the bundle time. Metro's cache key doesn't cover .env files,
167
+ # so a babel plugin inlining env values would reuse another flavor's output: clear the
168
+ # cache when this flavor's env file changed since the last build, or on --reset-cache.
130
169
  METRO_CACHE_DIR=$(node -e "console.log(require('os').tmpdir())")/metro-cache
131
- if [ -d "$METRO_CACHE_DIR" ]; then
132
- rm -rf "$METRO_CACHE_DIR"
170
+ METRO_KEY="$FLAVOR_LOWER $(shasum -a 256 < "$ROOT/.env.$FLAVOR_LOWER" | cut -c1-16)"
171
+ if [ "$RESET_CACHE" = true ] || [ "$(cat "$METRO_CACHE_DIR/.bspatch-env" 2>/dev/null)" != "$METRO_KEY" ]; then
172
+ rm -rf "$METRO_CACHE_DIR" # by hand: robust vs ENOTEMPTY
133
173
  info metro "stopped · cache cleared"
134
174
  else
135
- info metro "stopped"
175
+ info metro "stopped · cache reused ${DIM}(--reset-cache to clear)${RESET}"
136
176
  fi
177
+ mkdir -p "$METRO_CACHE_DIR" && echo "$METRO_KEY" > "$METRO_CACHE_DIR/.bspatch-env"
137
178
  fi
138
179
 
139
180
  mkdir -p "$ROOT/android/output"
@@ -147,35 +188,17 @@ if [ "$ACTION" != "setup" ]; then
147
188
  BASE_URL="${OTA_BASE_URL:-}"
148
189
  BASE_FOLDER="${OTA_FOLDER:-ota}"
149
190
 
150
- info storage "${OTA_R2_BUCKET:+R2 $OTA_R2_BUCKET → }$BASE_URL/$BASE_FOLDER"
151
-
152
- if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
153
- R2_URL="https://${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
154
- r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "${OTA_R2_ACCESS_KEY_ID:?}:${OTA_R2_SECRET_ACCESS_KEY:?}" "$@"; }
155
- # S3 API, not the public URL: always the latest object, no CDN cache
156
- API_HTTP=$(r2 -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "$R2_URL/$BASE_FOLDER/ota_registry.json" || echo 0)
157
- else
158
- if [[ "$BASE_URL" != "https://raw.githubusercontent.com/"* ]]; then
159
- echo " ❌ Set OTA_R2_* (Cloudflare R2) or OTA_BASE_URL=https://raw.githubusercontent.com/<owner>/<repo>/<branch>"
160
- exit 1
161
- fi
162
- CLEAN_URL="${BASE_URL#https://raw.githubusercontent.com/}"
163
- GH_OWNER=$(echo "$CLEAN_URL" | cut -d'/' -f1)
164
- GH_REPO=$(echo "$CLEAN_URL" | cut -d'/' -f2)
165
- GH_BRANCH=$(echo "$CLEAN_URL" | cut -d'/' -f3)
166
- if [[ -z "$GH_BRANCH" ]]; then GH_BRANCH="main"; fi
167
-
168
- # Contents API (not the raw CDN) always returns the latest commit; the raw
169
- # media type has no 1 MB limit and needs no base64 decoding.
170
- GH_API_TOKEN="$(gh auth token 2>/dev/null || true)"
171
- GH_API_TOKEN="${GH_API_TOKEN:-${GH_TOKEN:-${GITHUB_TOKEN:-}}}"
172
- GH_API_AUTH=()
173
- if [[ -n "$GH_API_TOKEN" ]]; then GH_API_AUTH=(-H "Authorization: Bearer $GH_API_TOKEN"); fi
174
-
175
- API_HTTP=$(curl -s -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "${GH_API_AUTH[@]}" \
176
- -H "Accept: application/vnd.github.raw" \
177
- "https://api.github.com/repos/${GH_OWNER}/${GH_REPO}/contents/${BASE_FOLDER}/ota_registry.json?ref=${GH_BRANCH}" || echo 0)
191
+ # Cloudflare R2 is the only storage for now (until custom servers get their own support)
192
+ if [[ -z "${OTA_R2_BUCKET:-}" ]]; then
193
+ echo " ❌ OTA_R2_BUCKET not set: add OTA_R2_ACCOUNT_ID / OTA_R2_BUCKET / OTA_R2_ACCESS_KEY_ID / OTA_R2_SECRET_ACCESS_KEY to .env.ota"
194
+ exit 1
178
195
  fi
196
+ info storage "R2 $OTA_R2_BUCKET → $BASE_URL/$BASE_FOLDER"
197
+
198
+ R2_URL="https://${OTA_R2_ACCOUNT_ID:?set OTA_R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${OTA_R2_BUCKET}"
199
+ r2() { curl -sS --aws-sigv4 "aws:amz:auto:s3" --user "${OTA_R2_ACCESS_KEY_ID:?}:${OTA_R2_SECRET_ACCESS_KEY:?}" "$@"; }
200
+ # S3 API, not the public URL: always the latest object, no CDN cache
201
+ API_HTTP=$(r2 -o "$REGISTRY_SHARED.tmp" -w "%{http_code}" "$R2_URL/$BASE_FOLDER/ota_registry.json" || echo 0)
179
202
 
180
203
  if [[ "$API_HTTP" == "200" ]] && node -e "JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))" "$REGISTRY_SHARED.tmp" 2>/dev/null; then
181
204
  mv "$REGISTRY_SHARED.tmp" "$REGISTRY_SHARED"
@@ -195,20 +218,12 @@ if [ "$ACTION" != "setup" ]; then
195
218
  fi
196
219
 
197
220
  # ── Helper: Download from remote storage ───────────────────────────────────────
198
- download_remote() {
199
- local REMOTE_PATH="$1"
200
- local DEST="$2"
201
- local OK=0
202
- if [[ -n "${OTA_R2_BUCKET:-}" ]]; then
203
- r2 -f -o "$DEST.tmp" "$R2_URL/$REMOTE_PATH" 2>/dev/null || OK=1
221
+ download_remote() { # <remote path> <dest>
222
+ # --compressed: bundles are stored gzip Content-Encoded
223
+ if r2 -f --compressed -o "$2.tmp" "$R2_URL/$1" 2>/dev/null; then
224
+ mv "$2.tmp" "$2"
204
225
  else
205
- # no-cache + timestamp query bypasses the GitHub CDN 5-min cache
206
- curl -s -f -H "Cache-Control: no-cache" -o "$DEST.tmp" "$BASE_URL/$REMOTE_PATH?cb=$(date +%s)" || OK=1
207
- fi
208
- if [ $OK -eq 0 ]; then
209
- mv "$DEST.tmp" "$DEST"
210
- else
211
- rm -f "$DEST.tmp"
226
+ rm -f "$2.tmp"
212
227
  return 1
213
228
  fi
214
229
  }
@@ -218,42 +233,102 @@ sha256() {
218
233
  shasum -a 256 "$1" | awk '{print $1}'
219
234
  }
220
235
 
221
- # ── Progress helpers (exported: upload_ota.sh uses them too) ──────────────────
236
+ # ── Progress ───────────────────────────────────────────────────────────────────
237
+ # BUILD Bundle JS 5.8 MB ██████░░░░░░░░ 18s
238
+ # BUILD Hermes ⠹ 12s · <last log line> ← running (terminal only, redrawn in place)
239
+ # Bars are each step's share of the platform's total time, so finished rows show an empty
240
+ # bar and timeline_redraw fills them in after the last step.
222
241
  human_size() {
223
242
  awk -v b="$1" 'BEGIN { split("B KB MB GB", u); i = 1; while (b >= 1024 && i < 4) { b /= 1024; i++ }
224
243
  printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
225
244
  }
226
245
  file_size() { wc -c < "$1" | tr -d ' '; }
227
- STEP=0
228
- STEP_TOTAL=5
229
- # One line per step: " [3/5] Assets 59 files · 1s". In a terminal the label shows
230
- # while the step runs and step_progress updates it in place.
231
- step_line() { printf " [%d/%d] %-16s %s" "$STEP" "$STEP_TOTAL" "$STEP_LABEL" "$1"; }
232
- step() {
233
- STEP=$((STEP + 1)); STEP_START=$SECONDS; STEP_LABEL="$1"
234
- if [ -t 1 ]; then step_line "${DIM}…${RESET}"; fi
246
+ row() { # <badge> <label> <detail> <time> [bar] [time color]
247
+ printf " %s %s ${BOLD}%s${RESET} %s${6:-$DIM}%4s${RESET}\n" "$(badge "$1")" "$(padr "$2" 11)" "$(padr "$3" 12)" "${5:+$5 }" "$4"
248
+ }
249
+ bar() { # <secs> <total secs> <badge>
250
+ local W=14 N=0 I C
251
+ if [ "$2" -gt 0 ]; then N=$(( ($1 * W + $2 - 1) / $2 )); fi
252
+ if [ "$N" -gt "$W" ]; then N=$W; fi
253
+ case "$3" in BUILD) C=75 ;; DIFF) C=177 ;; *) C=87 ;; esac
254
+ if [ -n "$RESET" ]; then printf "\033[38;5;%sm" "$C"; fi
255
+ for (( I = 0; I < N; I++ )); do printf "█"; done
256
+ printf "%s" "$RESET$DIM"
257
+ for (( I = N; I < W; I++ )); do printf "░"; done
258
+ printf "%s" "$RESET"
259
+ }
260
+ ROWS=() # "badge|label|detail|secs" of the current platform's finished steps
261
+ step() { # <label> <badge>
262
+ STEP_LABEL="$1"; STEP_BADGE="$2"; STEP_START=$(date +%s)
263
+ export STEP_LABEL STEP_BADGE STEP_START
264
+ step_progress
265
+ }
266
+ step_progress() { # [text]
267
+ [ -t 1 ] || return 0
268
+ local F=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
269
+ SPIN=$(( (${SPIN:-0} + 1) % 10 ))
270
+ printf "\r\033[K %s %s ${CYAN}%s${RESET} ${DIM}%s${RESET}" "$(badge "$STEP_BADGE")" "$(padr "$STEP_LABEL" 11)" "${F[$SPIN]}" "$1"
235
271
  }
236
- step_progress() { if [ -t 1 ]; then printf "\r\033[K"; step_line "${DIM}$1${RESET}"; fi; }
237
- step_done() {
272
+ step_done() { # <detail>
273
+ local SECS=$(( $(date +%s) - STEP_START ))
274
+ ROWS+=("$STEP_BADGE|$STEP_LABEL|$1|$SECS")
275
+ if [ -t 1 ]; then printf "\r\033[K"; row "$STEP_BADGE" "$STEP_LABEL" "$1" "${SECS}s" "$(bar 0 0 "$STEP_BADGE")"
276
+ else row "$STEP_BADGE" "$STEP_LABEL" "$1" "${SECS}s"; fi
277
+ }
278
+ step_fail() { # [message]; error details go below
238
279
  if [ -t 1 ]; then printf "\r\033[K"; fi
239
- step_line "$1 ${DIM}· $((SECONDS - STEP_START))s${RESET}"; printf "\n"
280
+ printf " %s %s ${RED}%s${RESET}\n" "$(badge FAIL)" "$(padr "$STEP_LABEL" 11)" "${1:-failed}"
281
+ }
282
+ timeline_redraw() { # rewrites the ROWS lines above the cursor with filled bars
283
+ [ -t 1 ] && [ ${#ROWS[@]} -gt 0 ] || return 0
284
+ local R B L D S TOTAL=0 MAX=0
285
+ for R in "${ROWS[@]}"; do
286
+ TOTAL=$(( TOTAL + ${R##*|} )); if [ "${R##*|}" -gt "$MAX" ]; then MAX=${R##*|}; fi
287
+ done
288
+ printf "\033[%dA" ${#ROWS[@]}
289
+ for R in "${ROWS[@]}"; do
290
+ IFS='|' read -r B L D S <<< "$R"
291
+ printf "\r\033[K"
292
+ # the slowest step stands out
293
+ if [ "$S" -eq "$MAX" ] && [ "$MAX" -gt 0 ]; then row "$B" "$L" "$D" "${S}s" "$(bar "$S" "$TOTAL" "$B")" "$BOLD$YELLOW"
294
+ else row "$B" "$L" "$D" "${S}s" "$(bar "$S" "$TOTAL" "$B")"; fi
295
+ done
240
296
  }
241
- step_fail() { if [ -t 1 ]; then printf "\n"; fi; } # leave the step line before error output
242
- # run_logged LOG cmd… — run cmd with output to LOG; in a terminal, tick elapsed time + the log's last line
297
+ detail() { printf "%10s%s %s\n" "" "$(padr "$1" 9)" "$2"; } # line under a step
298
+ vis_len() { # characters on screen: color codes don't count
299
+ local LC_ALL=en_US.UTF-8 S
300
+ S=$(printf "%s" "$1" | sed $'s/\033\\[[0-9;]*m//g')
301
+ printf "%s" "${#S}"
302
+ }
303
+ box() { # <line>… — rounded box sized to the widest line, border shading blue → green
304
+ local L W=0 N I EDGE="" PAL=(39 38 44 43 49 48 84 83 119 118)
305
+ for L in "$@"; do N=$(vis_len "$L"); if [ "$N" -gt "$W" ]; then W=$N; fi; done
306
+ local A="" Z=""; if [ -n "$RESET" ]; then A=$'\033[38;5;39m'; Z=$'\033[38;5;118m'; fi
307
+ for (( I = 0; I < W + 4; I++ )); do
308
+ if [ -n "$RESET" ]; then EDGE+=$'\033[38;5;'"${PAL[$(( I * 10 / (W + 4) ))]}m"; fi
309
+ EDGE+="─"
310
+ done
311
+ printf " ${A}╭%s${Z}╮${RESET}\n" "$EDGE"
312
+ for L in "$@"; do printf " ${A}│${RESET} %s%*s ${Z}│${RESET}\n" "$L" $((W - $(vis_len "$L"))) ""; done
313
+ printf " ${A}╰%s${Z}╯${RESET}\n" "$EDGE"
314
+ }
315
+ # [RUN_NOTE=…] run_logged LOG cmd… — output to LOG; in a terminal, spin with elapsed time + the log's last line
243
316
  run_logged() {
244
317
  local LOG="$1"; shift
245
318
  "$@" > "$LOG" 2>&1 &
246
319
  local PID=$!
247
320
  if [ -t 1 ]; then
321
+ local W=$(( $(tput cols 2>/dev/null || echo 80) - 40 ))
248
322
  while kill -0 "$PID" 2>/dev/null; do
249
- local LAST=$(tail -n 1 "$LOG" 2>/dev/null | tr -d '\r\033' | cut -c1-50)
250
- step_progress "$((SECONDS - STEP_START))s${LAST:+ · $LAST}"
251
- sleep 1
323
+ local LAST=$(tail -n 1 "$LOG" 2>/dev/null | tr -d '\r\033' | cut -c1-$(( W > 10 ? W : 10 )))
324
+ step_progress "${RUN_NOTE:+$RUN_NOTE · }$(( $(date +%s) - STEP_START ))s${LAST:+ · $LAST}"
325
+ sleep 0.2
252
326
  done
253
327
  fi
254
328
  wait "$PID"
255
329
  }
256
- export -f human_size file_size
330
+ # upload_ota.sh runs inside the Upload/Remote step: it spins, fails, and reports back through $BSPATCH_REPORT
331
+ export -f padr info badge human_size file_size step_progress step_fail run_logged
257
332
 
258
333
  # ── Release version (native app version the OTA targets) ─────────────────────
259
334
  android_version() {
@@ -294,6 +369,68 @@ if [ -z "$APP_VERSION" ]; then
294
369
  fi
295
370
  info release "$APP_VERSION"
296
371
 
372
+ # Release notes: shown in the summary and kept in release_*.json (public) for a dashboard
373
+ if [ "$ACTION" = "ota" ] && [ -t 0 ] && [ "$CONFIRM_VERSION" = true ] && [ -z "$NOTES_GIVEN" ]; then
374
+ read -r -p " ${BOLD}?${RESET} Release notes ${DIM}(optional, Enter to skip)${RESET}: " BSPATCH_NOTES
375
+ export BSPATCH_NOTES
376
+ fi
377
+
378
+ # ── Signing key: found, or created + embedded in the app (never replaces one in use) ──
379
+ if [ "$ACTION" = "ota" ]; then
380
+ KEY_FLAGS=(); if [ -t 0 ]; then KEY_FLAGS=(--interactive); fi
381
+ KEY_NOTE=$(node "$SCRIPT_DIR/ensure_signing_key.js" "$ROOT" "$REGISTRY_SHARED" "${KEY_FLAGS[@]}") || exit 1
382
+ info signing "$KEY_NOTE"
383
+ # Native wiring (resolver, fingerprint build step) kept current without a separate setup run
384
+ WIRED=$(node "$SCRIPT_DIR/setup_project.js" --root "$ROOT" --quiet | paste -sd ',' - | sed 's/,/, /g')
385
+ if [ -n "$WIRED" ]; then
386
+ info wiring "${YELLOW}updated: $WIRED${RESET} ${DIM}(commit; store builds from now on carry it)${RESET}"
387
+ else
388
+ info wiring "ok"
389
+ fi
390
+ fi
391
+
392
+ # ── Release controls, status, doctor (registry only, nothing is built) ──
393
+ if [ "$ACTION" = "admin" ]; then
394
+ if [ "$ADMIN_CMD" = "doctor" ]; then
395
+ exec node "$SCRIPT_DIR/doctor.js" "$ROOT" "$REGISTRY_SHARED" "$APP_VERSION" "$FLAVOR_LOWER" "$BASE_URL" "$BASE_FOLDER"
396
+ fi
397
+ RELEASES_DIR=$(mktemp -d)
398
+ if [ "$ADMIN_CMD" = "status" ]; then
399
+ # The live version's release_*.json per platform, for who/when/notes/sizes
400
+ for F in $(node -e '
401
+ const [reg, app, flavor] = process.argv.slice(1);
402
+ const g = JSON.parse(require("fs").readFileSync(reg, "utf8")).apps?.[app]?.flavors?.[flavor];
403
+ for (const p of new Set((g?.patches || []).map(e => e.platform)))
404
+ console.log(`release_${app}_v${g.latestOtaVersion}_${p}_${flavor}.json`);
405
+ ' "$REGISTRY_SHARED" "$APP_VERSION" "$FLAVOR_LOWER"); do
406
+ download_remote "$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$F" "$RELEASES_DIR/$F" || true
407
+ done
408
+ fi
409
+ ADMIN_FLAGS=(); if [ -n "$PLATFORM_ARG" ]; then ADMIN_FLAGS+=(--platform "$PLATFORM_ARG"); fi
410
+ if [ -n "$ADMIN_IDS" ]; then ADMIN_FLAGS+=(--ids "$ADMIN_IDS"); fi
411
+ if [ "$ADMIN_OFF" = true ]; then ADMIN_FLAGS+=(--off); fi
412
+ if [ "$JSON_OUT" = true ]; then ADMIN_FLAGS+=(--json); fi
413
+ printf "\n"
414
+ set +e
415
+ node "$SCRIPT_DIR/ota_admin.js" "$ADMIN_CMD" "$REGISTRY_SHARED" "$APP_VERSION" "$FLAVOR_LOWER" "$RELEASES_DIR" \
416
+ "${ADMIN_FLAGS[@]}" "${ADMIN_ARGS[@]}"
417
+ ADMIN_EXIT=$?
418
+ set -e
419
+ rm -rf "$RELEASES_DIR"
420
+ # Exit 3: the registry changed and goes back to storage
421
+ if [ $ADMIN_EXIT -eq 3 ]; then
422
+ if [ "$DRY_RUN" = true ]; then
423
+ info registry "not uploaded ${DIM}(dry run)${RESET}"
424
+ else
425
+ r2 -f -X PUT -T "$REGISTRY_SHARED" -H "Cache-Control: no-cache" "$R2_URL/$BASE_FOLDER/ota_registry.json" -o /dev/null \
426
+ || { echo " ❌ Uploading the registry failed"; exit 1; }
427
+ info registry "uploaded · clients see it on their next check"
428
+ fi
429
+ exit 0
430
+ fi
431
+ exit $ADMIN_EXIT
432
+ fi
433
+
297
434
  # ── Reset ─────────────────────────────────────────────────────────────────────
298
435
  if [ "$ACTION" = "reset" ]; then
299
436
  # Reset only the specific flavor in registry JSON if registry exists
@@ -326,7 +463,12 @@ if [ "$ACTION" = "reset" ]; then
326
463
  info local "cleared $APP_VERSION/$FLAVOR_LOWER · next OTA is v$((LATEST + 1))"
327
464
 
328
465
  if [ "$PUSH_REMOTE" = true ]; then
329
- bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "0" "" "ios" "--clean-remote"
466
+ printf "\n"; step "Remote" SHIP
467
+ export BSPATCH_REPORT=$(mktemp)
468
+ bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "0" "" "ios" "--clean-remote" || exit 1
469
+ . "$BSPATCH_REPORT"; rm -f "$BSPATCH_REPORT"
470
+ step_done "$UP_SIZE"
471
+ if [ -n "$UP_NOTE" ]; then detail "" "${DIM}$UP_NOTE${RESET}"; fi
330
472
  else
331
473
  info remote "unchanged ${DIM}(add --push to delete on storage)${RESET}"
332
474
  fi
@@ -369,8 +511,8 @@ build_platform() {
369
511
  if [ "$PLATFORM" == "ios" ]; then OUTPUT="$ROOT/ios/output"; fi
370
512
  local REGISTRY="$REGISTRY_SHARED"
371
513
 
372
- printf "\n${BOLD}%s${RESET} · %s · ${BOLD}v%s${RESET} ${DIM}(previous v%s)${RESET}\n" "$PLATFORM" "$APP_VERSION" "$NEXT" "$CURRENT"
373
- STEP=0
514
+ printf "\n%s ${BOLD}%s${RESET} ${DIM}· v%s →${RESET} ${BOLD}${GREEN}v%s${RESET}\n\n" "$(badge "$(echo "$PLATFORM" | tr '[:lower:]' '[:upper:]')")" "$APP_VERSION" "$CURRENT" "$NEXT"
515
+ ROWS=()
374
516
  local PLATFORM_START=$SECONDS
375
517
 
376
518
  # ── Bundle ──
@@ -378,20 +520,60 @@ build_platform() {
378
520
  local WORKSPACE="$OUTPUT/workspace"
379
521
  mkdir -p "$WORKSPACE"
380
522
 
523
+ local BUNDLE_NAME="bundle_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
524
+ local NEW_BUNDLE_PATH="$OUTPUT/$BUNDLE_NAME"
525
+ local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.json"
526
+ local ASSET_STORE_DIR="$OUTPUT/asset-store"
527
+ # This version's assets only: upload checks each file, earlier versions' are already on storage
528
+ rm -rf "$ASSET_STORE_DIR"; mkdir -p "$ASSET_STORE_DIR"
529
+ unset BSPATCH_FINGERPRINT
530
+
531
+ if [ -n "$SOURCE_FLAVOR" ]; then
532
+ # ── Source: a bundle already on storage (promote / rollback), nothing is built ──
533
+ local SRC_V="${SOURCE_VERSION:-$(node -e '
534
+ const [reg, app, flavor] = process.argv.slice(1);
535
+ console.log(JSON.parse(require("fs").readFileSync(reg, "utf8")).apps?.[app]?.flavors?.[flavor]?.latestOtaVersion || "");
536
+ ' "$REGISTRY_SHARED" "$APP_VERSION" "$SOURCE_FLAVOR")}"
537
+ local SRC_PREFIX="$BASE_FOLDER/$APP_VERSION/$SOURCE_FLAVOR"
538
+ local SRC_SUFFIX="${APP_VERSION}_v${SRC_V}_${PLATFORM}_${SOURCE_FLAVOR}"
539
+ step "Source" BUILD
540
+ if [ -z "$SRC_V" ]; then step_fail "$SOURCE_FLAVOR has no OTA for $APP_VERSION"; exit 1; fi
541
+ step_progress "fetching $SOURCE_FLAVOR v$SRC_V…"
542
+ if ! download_remote "$SRC_PREFIX/bundle_$SRC_SUFFIX.hbc.gz" "$WORKSPACE/source.hbc.gz"; then
543
+ step_fail "$SOURCE_FLAVOR v$SRC_V has no $PLATFORM bundle on storage"; exit 1
544
+ fi
545
+ gunzip -c "$WORKSPACE/source.hbc.gz" > "$NEW_BUNDLE_PATH"
546
+ # Assets are content-addressed and already on storage: its manifest is all this needs
547
+ if ! download_remote "$SRC_PREFIX/asset-manifest_$SRC_SUFFIX.json" "$ASSET_MANIFEST_FILE"; then
548
+ step_fail "$SOURCE_FLAVOR v$SRC_V has no asset manifest on storage"; exit 1
549
+ fi
550
+ # Its fingerprint, not this checkout's: the bundle was built from that commit
551
+ if download_remote "$SRC_PREFIX/release_$SRC_SUFFIX.json" "$WORKSPACE/source-release.json"; then
552
+ export BSPATCH_FINGERPRINT=$(node -p 'require(process.argv[1]).runtimeFingerprint || ""' "$WORKSPACE/source-release.json")
553
+ fi
554
+ if [ -z "$BSPATCH_NOTES" ]; then
555
+ if [ "$PUBLISH_MODE" = "rollback" ]; then export BSPATCH_NOTES="rollback to v$SRC_V"
556
+ else export BSPATCH_NOTES="promoted from $SOURCE_FLAVOR v$SRC_V"; fi
557
+ fi
558
+ step_done "$SOURCE_FLAVOR v$SRC_V"
559
+ else
560
+
381
561
  # Determine source bundle
382
562
  local BUILD_LOG="$WORKSPACE/bundle.log"
383
563
  if [ "$PLATFORM" == "android" ]; then
384
- step "Bundle JS"
564
+ step "Bundle JS" BUILD
385
565
  # Gradle's config noise goes to a log; shown only on failure
386
566
  if ! run_logged "$BUILD_LOG" bash -c 'cd "$1/android" && ./gradlew ":app:createBundle${2}ReleaseJsAndAssets" -Pkotlin.incremental=false --quiet' _ "$ROOT" "$FLAVOR_CAP"; then
387
- step_fail; tail -n 40 "$BUILD_LOG"; echo " ❌ Gradle bundle failed (full log: $BUILD_LOG)"; exit 1
567
+ step_fail "Gradle bundle failed"; tail -n 40 "$BUILD_LOG"; echo " full log: $BUILD_LOG"; exit 1
388
568
  fi
389
569
  BUNDLE_SOURCE="$ROOT/android/app/build/generated/assets/react/${FLAVOR_LOWER}Release/index.android.bundle"
390
570
  else
391
- step "Bundle JS"
571
+ step "Bundle JS" BUILD
572
+ # Fresh dir: leftovers of deleted/renamed assets would land in the manifest
573
+ rm -rf "$OUTPUT/assets"
392
574
  if ! run_logged "$BUILD_LOG" npx react-native bundle --platform ios --dev false --entry-file index.js \
393
575
  --bundle-output "$WORKSPACE/source.bundle" --assets-dest "$OUTPUT/assets"; then
394
- step_fail; tail -n 40 "$BUILD_LOG"; echo " ❌ Bundle failed (full log: $BUILD_LOG)"; exit 1
576
+ step_fail "bundle failed"; tail -n 40 "$BUILD_LOG"; echo " full log: $BUILD_LOG"; exit 1
395
577
  fi
396
578
  BUNDLE_SOURCE="$WORKSPACE/source.bundle"
397
579
  fi
@@ -406,35 +588,29 @@ build_platform() {
406
588
  if [ "$FIRST_BYTE" == "c6" ]; then IS_HBC=true; fi
407
589
  fi
408
590
 
409
- step "Hermes"
410
- local HERMES_NOTE="already bytecode"
591
+ step "Hermes" BUILD
411
592
  if [ "$IS_HBC" != true ]; then
412
- HERMES_NOTE="compiled"
413
593
  if [ -z "$HERMESC_PATH" ] || [ ! -f "$HERMESC_PATH" ]; then
414
- step_fail; echo " ❌ ERROR: hermesc not found"; exit 1
594
+ step_fail "hermesc not found"; exit 1
595
+ fi
596
+ # -O matches what Xcode/Gradle use for release; -w: undeclared-global warnings are expected noise
597
+ if ! run_logged "$WORKSPACE/hermes.log" "$HERMESC_PATH" -emit-binary -O -w -out "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"; then
598
+ step_fail "hermesc failed"; tail -n 40 "$WORKSPACE/hermes.log"; exit 1
415
599
  fi
416
- # -O matches what Xcode/Gradle use for release
417
- "$HERMESC_PATH" -emit-binary -O -out "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
418
600
  mv "$WORKSPACE/source.hbc" "$BUNDLE_SOURCE"
419
601
  fi
420
602
 
421
- local BUNDLE_NAME="bundle_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
422
- local NEW_BUNDLE_PATH="$OUTPUT/$BUNDLE_NAME"
423
-
424
603
  cp "$BUNDLE_SOURCE" "$NEW_BUNDLE_PATH"
425
- step_done "$HERMES_NOTE"
604
+ step_done "$(human_size "$(file_size "$NEW_BUNDLE_PATH")")"
426
605
 
427
606
  # ── Assets ──
428
- local ASSET_MANIFEST_FILE="$OUTPUT/asset-manifest_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.json"
429
- local ASSET_STORE_DIR="$OUTPUT/asset-store"
430
- mkdir -p "$ASSET_STORE_DIR"
431
607
  local ASSETS_SRC_DIR="$ROOT/android/app/build/generated/res/react/${FLAVOR_LOWER}Release"
432
608
  if [ "$PLATFORM" == "ios" ]; then ASSETS_SRC_DIR="$OUTPUT/assets"; fi
433
609
 
434
610
  local ASSET_JSON_LIST="["
435
611
  local FIRST=true
436
612
  local ASSET_COUNT=0
437
- step "Assets"
613
+ step "Assets" BUILD
438
614
  if [ -d "$ASSETS_SRC_DIR" ]; then
439
615
  while IFS= read -r -d '' ASSET_FILE; do
440
616
  local HASH=$(sha256 "$ASSET_FILE")
@@ -458,6 +634,7 @@ build_platform() {
458
634
  cat > "$ASSET_MANIFEST_FILE" << EOF
459
635
  { "version": $NEXT, "appVersion": "$APP_VERSION", "platform": "$PLATFORM", "flavor": "$FLAVOR_LOWER", "assets": $ASSET_JSON_LIST }
460
636
  EOF
637
+ fi
461
638
 
462
639
  # ── Patches ──
463
640
  # From the last 5 OTA versions only (never v1 = store build). Clients on the
@@ -465,46 +642,96 @@ EOF
465
642
  local NEW_HASH=$(sha256 "$NEW_BUNDLE_PATH")
466
643
  local PATCHES_JSON="["
467
644
  local FIRST_FROM=$((NEXT - 5)); if [ $FIRST_FROM -lt 2 ]; then FIRST_FROM=2; fi
468
- local PATCH_TOTAL=$((NEXT - FIRST_FROM)) PATCH_MADE=0 PATCH_LINES=() PATCH_MISSING=""
469
- step "Patches"
645
+ local PATCH_SIZES=() PATCH_MISSING="" # "from|bytes"
646
+ step "Patches" DIFF
470
647
  for (( i=FIRST_FROM; i<NEXT; i++ )); do
471
648
  local OLD_B="bundle_${APP_VERSION}_v${i}_${PLATFORM}_${FLAVOR_LOWER}.hbc"
472
649
  local OLD_P="$OUTPUT/$OLD_B"
473
650
  if [ ! -f "$OLD_P" ]; then
474
651
  step_progress "v$i: fetching bundle…"
475
- local REMOTE_BUNDLE_PATH="$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$OLD_B"
476
- download_remote "$REMOTE_BUNDLE_PATH" "$OLD_P" || true
652
+ if download_remote "$BASE_FOLDER/$APP_VERSION/$FLAVOR_LOWER/$OLD_B.gz" "$OLD_P.gz"; then
653
+ gunzip -c "$OLD_P.gz" > "$OLD_P" || rm -f "$OLD_P"
654
+ rm -f "$OLD_P.gz"
655
+ fi
477
656
  fi
478
657
  if [ ! -f "$OLD_P" ]; then
479
658
  PATCH_MISSING="${PATCH_MISSING:+$PATCH_MISSING, }v$i"
480
659
  continue
481
660
  fi
482
661
  local PNAME="patch_${APP_VERSION}_v${i}_to_v${NEXT}_${FLAVOR_LOWER}_${PLATFORM}.patch"
483
- local PATCH_START=$SECONDS
484
662
  step_progress "v$i → v$NEXT…"
485
663
  "$BSDIFF" "$OLD_P" "$NEW_BUNDLE_PATH" "$OUTPUT/$PNAME"
486
- PATCH_LINES+=("v$i → v$NEXT|$(human_size "$(file_size "$OUTPUT/$PNAME")") ${DIM}· $((SECONDS - PATCH_START))s${RESET}")
487
- PATCH_MADE=$((PATCH_MADE + 1))
664
+ PATCH_SIZES+=("$i|$(file_size "$OUTPUT/$PNAME")")
488
665
  if [ "$PATCHES_JSON" != "[" ]; then PATCHES_JSON+=", "; fi
489
666
  PATCHES_JSON+="{\"from\": $i, \"to\": $NEXT, \"file\": \"$PNAME\", \"baseHash\": \"$(sha256 "$OLD_P")\", \"bundleHash\": \"$NEW_HASH\"}"
490
667
  done
491
668
  PATCHES_JSON+="]"
492
- if [ $PATCH_TOTAL -eq 0 ]; then
493
- step_done "none ${DIM}(first OTA of this release: full bundle only)${RESET}"
494
- else
495
- step_done "$PATCH_MADE created"
496
- fi
497
- local L
498
- for L in "${PATCH_LINES[@]}"; do sub "${L%%|*}" "${L#*|}"; done
499
- if [ -n "$PATCH_MISSING" ]; then sub "$PATCH_MISSING" "${DIM}no bundle on storage → full download${RESET}"; fi
669
+ case ${#PATCH_SIZES[@]} in
670
+ 0) step_done "none" ;;
671
+ 1) step_done "1 × $(human_size "${PATCH_SIZES[0]#*|}")" ;;
672
+ *) step_done "${#PATCH_SIZES[@]} patches" ;;
673
+ esac
500
674
 
501
675
  # ── Finalize ──
502
- step "Publish"
503
- if [ -t 1 ]; then printf "\r\033[K"; fi
504
- step_line ""; printf "\n"
505
- node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "$NEXT" "$PATCHES_JSON" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"
506
- bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM"
507
- printf "${GREEN}✓${RESET} ${BOLD}%s v%s${RESET} published ${DIM}in %ss${RESET}\n" "$PLATFORM" "$NEXT" "$((SECONDS - PLATFORM_START))"
676
+ step "Upload" SHIP
677
+ export BSPATCH_REPORT="$WORKSPACE/upload.report"; : > "$BSPATCH_REPORT"
678
+ local REGISTRY_NOTE
679
+ if ! REGISTRY_NOTE=$(node "$SCRIPT_DIR/update_registry.js" "$REGISTRY_SHARED" "$APP_VERSION" "$NEXT" "$PATCHES_JSON" "$PLATFORM" "$ASSET_MANIFEST_FILE" "$FLAVOR_LOWER"); then
680
+ step_fail "registry update failed"; exit 1
681
+ fi
682
+ local UP_SIZE="" UP_DEST="" UP_ASSETS="" UP_GZIP_BYTES="" NATIVE_CHANGED="" VERIFY_NOTE=""
683
+ if [ "$DRY_RUN" = true ]; then
684
+ step_done "skipped"
685
+ else
686
+ bash "$SCRIPT_DIR/upload_ota.sh" "$APP_VERSION" "$FLAVOR_LOWER" "$NEXT" "$NEW_BUNDLE_PATH" "$PLATFORM" || exit 1
687
+ . "$BSPATCH_REPORT"
688
+ step_done "$UP_SIZE"
689
+ # What the app will read: its own public URL, not the bucket API
690
+ step "Verify" SHIP
691
+ if ! VERIFY_NOTE=$(node "$SCRIPT_DIR/verify_publish.js" "$ROOT" "$BASE_URL" "$BASE_FOLDER" "$APP_VERSION" "$FLAVOR_LOWER" "$PLATFORM" "$NEXT" 2>"$WORKSPACE/verify.log"); then
692
+ step_fail "$(head -n 1 "$WORKSPACE/verify.log")"; tail -n +2 "$WORKSPACE/verify.log"; exit 1
693
+ fi
694
+ step_done "$VERIFY_NOTE"
695
+ fi
696
+ . "$BSPATCH_REPORT"
697
+ timeline_redraw
698
+
699
+ # ── Summary: what each client downloads ──
700
+ # What a client downloads for the full bundle: the gzip size on the wire
701
+ local RAW_BYTES=$(file_size "$NEW_BUNDLE_PATH") FULL_BYTES=${UP_GZIP_BYTES:-$(file_size "$NEW_BUNDLE_PATH")} L CARD=()
702
+ kv() { CARD+=("${CYAN}$(padr "$1" 10)${RESET}$2"); }
703
+ if [ "$DRY_RUN" = true ]; then
704
+ CARD+=("$(badge DONE) ${BOLD}$PLATFORM v$NEXT built${RESET} in ${YELLOW}$((SECONDS - PLATFORM_START))s${RESET} ${DIM}· dry run, nothing uploaded${RESET}" "")
705
+ else
706
+ CARD+=("$(badge DONE) ${BOLD}$PLATFORM ${GREEN}v$NEXT${RESET}${BOLD} is live${RESET} in ${YELLOW}$((SECONDS - PLATFORM_START))s${RESET} ${DIM}· $UP_DEST${RESET}" "")
707
+ fi
708
+ for L in "${PATCH_SIZES[@]}"; do
709
+ kv "v${L%%|*} → v$NEXT" "${BOLD}$(human_size "${L#*|}")${RESET} patch ${BOLD}${GREEN}$(awk -v p="${L#*|}" -v f="$FULL_BYTES" 'BEGIN { printf "−%.1f%%", 100 - p * 100 / f }')${RESET}"
710
+ done
711
+ kv "v1 → v$NEXT" "${BOLD}$(human_size "$FULL_BYTES")${RESET} full bundle ${DIM}(gzip · $(human_size "$RAW_BYTES") raw)${RESET}"
712
+ if [ -n "$PATCH_MISSING" ]; then kv "$PATCH_MISSING" "${DIM}no bundle on storage → full bundle${RESET}"; fi
713
+ if [ -n "$UP_ASSETS" ]; then kv "assets" "$UP_ASSETS"; fi
714
+ local COMMIT=$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null)
715
+ if [ -n "$COMMIT" ]; then
716
+ local DIRTY=""; if [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=no 2>/dev/null)" ]; then DIRTY=" ${YELLOW}+ uncommitted changes${RESET}"; fi
717
+ kv "commit" "$COMMIT ${DIM}$(git -C "$ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)${RESET}$DIRTY"
718
+ fi
719
+ if [ -n "$BSPATCH_NOTES" ]; then kv "notes" "$BSPATCH_NOTES"; fi
720
+ case "$REGISTRY_NOTE" in
721
+ *unsigned*) kv "signing" "${YELLOW}unsigned · no ota_private_key.pem${RESET}" ;;
722
+ *) kv "signing" "${GREEN}✓${RESET} signed" ;;
723
+ esac
724
+ local RELEASE_FILE="$OUTPUT/release_${APP_VERSION}_v${NEXT}_${PLATFORM}_${FLAVOR_LOWER}.json"
725
+ local FP=$(node -p 'require(process.argv[1]).runtimeFingerprint' "$RELEASE_FILE" 2>/dev/null)
726
+ if [ -n "$NATIVE_CHANGED" ]; then
727
+ kv "native" "$FP ${YELLOW}changed since $NATIVE_CHANGED: only store builds of this code take it${RESET}"
728
+ else
729
+ kv "native" "$FP"
730
+ fi
731
+ printf "\n"; box "${CARD[@]}"
732
+ if [ "$JSON_OUT" = true ]; then
733
+ node -e 'process.stdout.write(JSON.stringify(require(process.argv[1])) + "\n")' "$RELEASE_FILE"
734
+ fi
508
735
  }
509
736
 
510
737
  # ── Main ───────────────────────────────────────────────────────────────────