@radhya/mach 2.0.30 → 2.0.32

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.
@@ -0,0 +1,1004 @@
1
+ # Mach OTA Implementation Plan
2
+
3
+ ## Goal
4
+
5
+ Build a Mach-owned over-the-air update system for Expo and vanilla React Native apps without depending on EAS Update or App Center.
6
+
7
+ Mach should own the update publish command, storage, release metadata, channel routing, runtime compatibility, dashboard history, rollback, and download URLs. Expo may be used as an open-source client/runtime only when the project is Expo or explicitly chooses `expo-updates`.
8
+
9
+ ## Current State Check
10
+
11
+ ### CLI
12
+
13
+ Existing files:
14
+
15
+ - `src/index.ts` registers `mach update`.
16
+ - `src/commands/update.ts` implements the current OTA prototype.
17
+ - `src/lib/config.ts` has a small `update` block with `channel` and `url`.
18
+ - `README.md` lists `mach update`.
19
+
20
+ Current `mach update` flow:
21
+
22
+ 1. Reads `mach.config.json`.
23
+ 2. Defaults channel to `production`.
24
+ 3. Uses `package.json` version as `runtimeVersion`.
25
+ 4. Runs `npx expo export --platform all`.
26
+ 5. Zips `dist/`.
27
+ 6. Uploads the zip with `/builds/source-upload-url`, which stores it as `builds/<updateId>/source.zip`.
28
+ 7. Calls `/updates/publish` twice, once for Android and once for iOS, both pointing `manifestKey` at the uploaded zip key.
29
+
30
+ Problems:
31
+
32
+ - It assumes every project is Expo.
33
+ - It uses `expo export` for vanilla React Native, which is wrong.
34
+ - It uploads a zip but tells the API it is a manifest.
35
+ - It does not generate or upload individual immutable assets.
36
+ - It does not produce a valid Expo Updates manifest.
37
+ - It does not support vanilla RN client setup.
38
+ - It does not support setup, verify, list, rollback, promote, rollout, or dashboard visibility.
39
+ - It does not use build profile or latest successful Mach build runtime.
40
+ - It has no native-change guard.
41
+ - It uses `update` as the public command name, which is too generic.
42
+
43
+ ### API
44
+
45
+ Existing files:
46
+
47
+ - `mach-api/api/src/index.ts` defines `TABLE_UPDATES`.
48
+ - `GET /api/manifest` returns an Expo-style manifest response.
49
+ - `POST /updates/publish` stores update metadata.
50
+
51
+ Current API behavior:
52
+
53
+ 1. Manifest route reads `expo-runtime-version`, `expo-channel-name`, and `expo-platform`.
54
+ 2. It scans `MachUpdates` by channel, platform, and runtime version.
55
+ 3. It sorts by `createdAt`.
56
+ 4. It reads `latestUpdate.manifestKey` from S3.
57
+ 5. It parses the S3 object body as JSON.
58
+
59
+ Problems:
60
+
61
+ - It does not filter by `projectId`, so updates can collide across projects.
62
+ - It uses `ScanCommand`, not a query-friendly table/index.
63
+ - It reads a zip uploaded by the CLI and tries to parse it as JSON.
64
+ - It has no `GET /updates`, rollback, promote, disable, delete, or asset download routes.
65
+ - It has no `MachUpdates` table setup script.
66
+ - It does not support directives such as rollback-to-embedded for Expo.
67
+ - It does not support a vanilla RN update protocol.
68
+ - It does not expose low-cost immutable public asset URLs cleanly.
69
+
70
+ ### Dashboard
71
+
72
+ Existing dashboard docs only mention `mach update` in the command list. There is no OTA updates screen, update history, rollback UI, rollout UI, channel UI, or runtime compatibility explanation.
73
+
74
+ ### Existing Mach Patterns To Reuse
75
+
76
+ Deep-link automation already solved several UX problems that OTA should copy:
77
+
78
+ - Compact Mach config.
79
+ - Command flags have highest precedence.
80
+ - Auto-detect framework when config is missing.
81
+ - Prompt only for missing values.
82
+ - Expo config plugin with packaged plugin preferred.
83
+ - Local fallback plugin only when package plugin cannot be used.
84
+ - `setup` runs `verify` at the end.
85
+ - Dynamic Expo config is patched only when safe.
86
+
87
+ Build framework adapters also provide a good pattern:
88
+
89
+ - `src/lib/builds/frameworks/expo.ts`
90
+ - `src/lib/builds/frameworks/react-native.ts`
91
+ - `src/lib/builds/frameworks/types.ts`
92
+
93
+ OTA should introduce a parallel adapter boundary rather than growing `src/commands/update.ts`.
94
+
95
+ ## External Constraints
96
+
97
+ Expo:
98
+
99
+ - `expo-updates` can point at a custom update server by setting `updates.url` and `runtimeVersion`.
100
+ - The custom server must implement the Expo Updates protocol.
101
+ - Expo update manifests include `id`, `createdAt`, `runtimeVersion`, `launchAsset`, `assets`, `metadata`, and `extra`.
102
+ - Runtime version is the compatibility boundary between native binary and JS/assets.
103
+
104
+ Vanilla React Native:
105
+
106
+ - OTA can update JavaScript bundles and static assets only.
107
+ - OTA must not be published when native files, native dependencies, pods, Gradle, permissions, entitlements, or manifest settings changed without a new binary.
108
+ - Vanilla RN should not require Expo unless the user explicitly chooses `expo-updates`.
109
+
110
+ CodePush/App Center:
111
+
112
+ - App Center features, including CodePush service, were retired after March 31, 2025.
113
+ - Microsoft published standalone CodePush server/client compatibility material, but those repos are archived.
114
+ - Mach should not depend on App Center or treat CodePush as the core architecture.
115
+
116
+ References:
117
+
118
+ - https://docs.expo.dev/versions/latest/sdk/updates/
119
+ - https://docs.expo.dev/technical-specs/expo-updates-1/
120
+ - https://docs.expo.dev/eas-update/runtime-versions/
121
+ - https://learn.microsoft.com/en-us/appcenter/retirement
122
+
123
+ ## Product Decision
124
+
125
+ Use a new command namespace:
126
+
127
+ ```bash
128
+ mach ota
129
+ ```
130
+
131
+ Keep legacy compatibility:
132
+
133
+ ```bash
134
+ mach update
135
+ ```
136
+
137
+ `mach update` should become a deprecated alias for:
138
+
139
+ ```bash
140
+ mach ota publish
141
+ ```
142
+
143
+ Show a warning:
144
+
145
+ ```text
146
+ mach update is deprecated. Use mach ota publish.
147
+ ```
148
+
149
+ ## Command Surface
150
+
151
+ Primary commands:
152
+
153
+ ```bash
154
+ mach ota setup
155
+ mach ota publish
156
+ mach ota verify
157
+ mach ota list
158
+ mach ota rollback
159
+ mach ota promote
160
+ ```
161
+
162
+ Useful flags:
163
+
164
+ ```bash
165
+ mach ota setup --profile production
166
+ mach ota setup --platform ios
167
+ mach ota setup --platform android
168
+ mach ota setup --engine expo-updates
169
+ mach ota setup --engine mach-react-native
170
+ mach ota setup --yes
171
+
172
+ mach ota publish --channel production
173
+ mach ota publish --profile production
174
+ mach ota publish --platform all
175
+ mach ota publish --message "Fix login crash"
176
+ mach ota publish --runtime-version 1.2.0
177
+ mach ota publish --rollout 10
178
+ mach ota publish --mandatory
179
+ mach ota publish --skip-native-check
180
+
181
+ mach ota verify
182
+ mach ota verify --live
183
+ mach ota verify --native-check
184
+
185
+ mach ota list --channel production
186
+ mach ota rollback --channel production
187
+ mach ota rollback --to <updateId>
188
+ mach ota promote --from staging --to production --update-id <updateId>
189
+ ```
190
+
191
+ `--skip-native-check` should be dangerous and explicit. It should require `--yes` in CI or confirmation interactively.
192
+
193
+ ## Config Shape
194
+
195
+ Use a new `ota` block for clarity. Read legacy `update` as a backward-compatible alias, but migrate it during `mach ota setup`.
196
+
197
+ Minimal generated config:
198
+
199
+ ```json
200
+ {
201
+ "framework": "expo",
202
+ "projectId": "project-id",
203
+ "ota": {
204
+ "channel": "production"
205
+ }
206
+ }
207
+ ```
208
+
209
+ Full shape:
210
+
211
+ ```json
212
+ {
213
+ "ota": {
214
+ "engine": "auto",
215
+ "channel": "production",
216
+ "runtimeVersionPolicy": "nativeFingerprint",
217
+ "runtimeVersion": null,
218
+ "url": "https://mach-api.securejs.in/api/ota/expo/project-id",
219
+ "checkAutomatically": "ON_LOAD",
220
+ "fallbackToCacheTimeout": 0,
221
+ "codeSigning": {
222
+ "enabled": false,
223
+ "keyId": "root"
224
+ },
225
+ "reactNative": {
226
+ "entryFile": "index.js",
227
+ "iosBundleOutput": "main.jsbundle",
228
+ "androidBundleOutput": "index.android.bundle"
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ Keep config compact. Do not duplicate values already available elsewhere:
235
+
236
+ - `projectId` from root config.
237
+ - platform package/bundle identifiers from root `android` and `ios` config.
238
+ - profile-specific platform settings from `build.<profile>`.
239
+ - Expo `runtimeVersion` from app config when already present.
240
+
241
+ Precedence:
242
+
243
+ ```text
244
+ command flags
245
+ > ota config
246
+ > legacy update config
247
+ > selected build profile
248
+ > root mach.config.json
249
+ > Expo app config/native files/package.json
250
+ > prompts/defaults
251
+ ```
252
+
253
+ ## Engine Detection
254
+
255
+ `ota.engine` should be optional.
256
+
257
+ Supported values:
258
+
259
+ ```text
260
+ auto
261
+ expo-updates
262
+ mach-react-native
263
+ codepush-compatible
264
+ ```
265
+
266
+ Initial implementation should support:
267
+
268
+ - `expo-updates`
269
+ - `mach-react-native`
270
+
271
+ Later implementation may add:
272
+
273
+ - `codepush-compatible`
274
+
275
+ Detection rules:
276
+
277
+ 1. If command has `--engine`, use it.
278
+ 2. Else if `ota.engine` exists and is not `auto`, use it.
279
+ 3. Else if `framework` is `expo`, prefer `expo-updates`.
280
+ 4. Else if `framework` is `react-native`, prefer `mach-react-native`.
281
+ 5. Else inspect `package.json`, Expo config files, `ios/`, and `android/`.
282
+ 6. If ambiguous and interactive, ask.
283
+ 7. If ambiguous and non-interactive, fail with a clear message.
284
+
285
+ Do not require users to set `engine` in normal projects.
286
+
287
+ ## Runtime Version Strategy
288
+
289
+ Runtime version is required for OTA.
290
+
291
+ Definition:
292
+
293
+ ```text
294
+ The native runtime compatibility identifier for a build. An OTA update may only run on app binaries with the same runtime version.
295
+ ```
296
+
297
+ Mach should support these policies:
298
+
299
+ ```text
300
+ manual use ota.runtimeVersion or --runtime-version
301
+ appVersion use app version
302
+ nativeVersion use app version plus iOS build number or Android version code
303
+ nativeFingerprint hash native-affecting files and dependency metadata
304
+ latestBuild use latest successful Mach build runtime for profile/channel
305
+ ```
306
+
307
+ Default:
308
+
309
+ ```text
310
+ nativeFingerprint for new Mach OTA setup
311
+ ```
312
+
313
+ Why:
314
+
315
+ - `package.json` version is not enough.
316
+ - app version is easy but can be forgotten.
317
+ - native fingerprint is safer because native-affecting changes create a new runtime.
318
+ - latest successful Mach build should be used when publishing after a Mach build.
319
+
320
+ Runtime version resolution:
321
+
322
+ 1. `--runtime-version`
323
+ 2. `ota.runtimeVersion`
324
+ 3. Expo platform-specific `runtimeVersion`
325
+ 4. Expo top-level `runtimeVersion`
326
+ 5. latest successful Mach build runtime for `projectId`, `profile`, `platform`
327
+ 6. computed `runtimeVersionPolicy`
328
+ 7. fail with setup guidance
329
+
330
+ Build records must be extended to store:
331
+
332
+ ```json
333
+ {
334
+ "runtimeVersions": {
335
+ "ios": "rv-ios",
336
+ "android": "rv-android"
337
+ },
338
+ "nativeFingerprints": {
339
+ "ios": "hash",
340
+ "android": "hash"
341
+ },
342
+ "ota": {
343
+ "enabled": true,
344
+ "engine": "expo-updates",
345
+ "channel": "production"
346
+ }
347
+ }
348
+ ```
349
+
350
+ ## Native Change Guard
351
+
352
+ Before publish, Mach should compare the current native fingerprint to the latest compatible build fingerprint.
353
+
354
+ Native-affecting files include:
355
+
356
+ ```text
357
+ package.json
358
+ package-lock.json
359
+ yarn.lock
360
+ pnpm-lock.yaml
361
+ bun.lockb
362
+ app.json
363
+ app.config.js
364
+ app.config.ts
365
+ expo-plugin files
366
+ ios/Podfile
367
+ ios/Podfile.lock
368
+ ios/*.xcodeproj/project.pbxproj
369
+ ios/**/Info.plist
370
+ ios/**/*.entitlements
371
+ android/build.gradle
372
+ android/settings.gradle
373
+ android/gradle.properties
374
+ android/app/build.gradle
375
+ android/app/src/main/AndroidManifest.xml
376
+ react-native.config.js
377
+ metro.config.js
378
+ babel.config.js
379
+ ```
380
+
381
+ Dependency classification:
382
+
383
+ - Any added/removed package that contains native code should block OTA.
384
+ - Expo modules, React Native modules, Firebase, maps, camera, permissions, notifications, and other native libraries should be treated as native-affecting.
385
+ - Pure JS dependencies can be allowed.
386
+
387
+ Initial implementation can be conservative:
388
+
389
+ - If lockfile or native config changed since latest runtime and Mach cannot prove it is safe, block OTA.
390
+ - Print the files that changed.
391
+ - Recommend `mach build`.
392
+
393
+ ## Storage Model
394
+
395
+ Use Cloudflare R2 as the default OTA artifact store. R2 avoids the large egress
396
+ cost that OTA assets can create on S3, while Mach API keeps private R2
397
+ credentials server-side and issues short-lived upload URLs to the CLI. S3 can
398
+ remain a fallback for self-hosted/internal deployments, but managed Mach OTA
399
+ should use R2.
400
+
401
+ Prefix:
402
+
403
+ ```text
404
+ ota/<projectId>/updates/<updateId>/
405
+ ota/<projectId>/<platform>/<runtimeVersion>/<channel>/manifest.json
406
+ ```
407
+
408
+ Objects:
409
+
410
+ ```text
411
+ ota/<projectId>/updates/<updateId>/<platform>/manifest.json
412
+ ota/<projectId>/updates/<updateId>/<platform>/metadata.json
413
+ ota/<projectId>/updates/<updateId>/<platform>/main.jsbundle
414
+ ota/<projectId>/updates/<updateId>/<platform>/assets/<hash>.<ext>
415
+ ota/<projectId>/<platform>/<runtimeVersion>/<channel>/manifest.json
416
+ ```
417
+
418
+ Rules:
419
+
420
+ - Assets are immutable.
421
+ - Assets should use content hash names.
422
+ - Manifest points to stable public R2 custom-domain URLs.
423
+ - R2 credentials are never shipped to mobile apps.
424
+ - R2 lifecycle rules can expire old inactive updates after a configurable retention period.
425
+ - Active updates must not be deleted while clients can request them.
426
+
427
+ Cost posture:
428
+
429
+ - No EAS Update cost.
430
+ - No App Center cost.
431
+ - No extra server if using existing Mach API.
432
+ - No managed OTA SaaS cost.
433
+ - Use DynamoDB `PAY_PER_REQUEST`.
434
+ - Use R2 public custom domain for assets and manifest pointers.
435
+ - Use Cloudflare cache/WAF controls on the custom domain when needed.
436
+
437
+ The only unavoidable costs are usage-based:
438
+
439
+ - R2 storage and operations.
440
+ - DynamoDB reads/writes.
441
+ - Mach API traffic.
442
+
443
+ ## DynamoDB Model
444
+
445
+ Create a proper `MachUpdates` table.
446
+
447
+ Recommended key:
448
+
449
+ ```text
450
+ PK: projectId
451
+ SK: updateId
452
+ ```
453
+
454
+ Recommended GSIs:
455
+
456
+ ```text
457
+ ChannelRuntimeIndex
458
+ PK: projectChannelRuntimePlatform
459
+ SK: createdAt
460
+
461
+ ChannelIndex
462
+ PK: projectChannel
463
+ SK: createdAt
464
+ ```
465
+
466
+ Derived keys:
467
+
468
+ ```text
469
+ projectChannel = <projectId>#<channel>
470
+ projectChannelRuntimePlatform = <projectId>#<channel>#<runtimeVersion>#<platform>
471
+ ```
472
+
473
+ Dashboard counters are aggregate fields on the update record. Do not write one
474
+ row per user or one row per download:
475
+
476
+ ```text
477
+ statsTotalIosDownloaded
478
+ statsTotalIosApplied
479
+ statsTotalIosFailed
480
+ statsTotalAndroidDownloaded
481
+ statsTotalAndroidApplied
482
+ statsTotalAndroidFailed
483
+ statsDailyYYYYMMDDIosDownloaded
484
+ statsDailyYYYYMMDDAndroidDownloaded
485
+ ```
486
+
487
+ This keeps dashboard counts accurate enough, private, and very cheap while
488
+ still allowing daily charts.
489
+
490
+ Item:
491
+
492
+ ```json
493
+ {
494
+ "projectId": "project-id",
495
+ "updateId": "uuid",
496
+ "channel": "production",
497
+ "platform": "ios",
498
+ "engine": "expo-updates",
499
+ "runtimeVersion": "runtime",
500
+ "runtimeVersionPolicy": "nativeFingerprint",
501
+ "nativeFingerprint": "hash",
502
+ "manifestKey": "ota/project/update/manifest.json",
503
+ "launchAssetKey": "ota/project/update/ios/main.jsbundle",
504
+ "assetKeys": [],
505
+ "message": "Fix login crash",
506
+ "commitSha": "abc123",
507
+ "branch": "main",
508
+ "status": "active",
509
+ "rollout": 100,
510
+ "mandatory": false,
511
+ "createdAt": 1783750000000,
512
+ "createdBy": "user-id",
513
+ "promotedFrom": null,
514
+ "disabledAt": null,
515
+ "rollbackOf": null
516
+ }
517
+ ```
518
+
519
+ Statuses:
520
+
521
+ ```text
522
+ draft
523
+ active
524
+ disabled
525
+ rolled_back
526
+ deleted
527
+ ```
528
+
529
+ Only `active` updates are served to clients.
530
+
531
+ ## API Plan
532
+
533
+ Replace the current generic manifest route with project-scoped routes.
534
+
535
+ Publish and management:
536
+
537
+ ```text
538
+ POST /projects/:projectId/ota/upload-url
539
+ POST /projects/:projectId/ota/publish
540
+ GET /projects/:projectId/ota/updates
541
+ GET /projects/:projectId/ota/updates/:updateId
542
+ POST /projects/:projectId/ota/updates/:updateId/promote
543
+ POST /projects/:projectId/ota/channels/:channel/rollback
544
+ PATCH /projects/:projectId/ota/updates/:updateId
545
+ ```
546
+
547
+ Client manifest routes:
548
+
549
+ ```text
550
+ GET /api/ota/expo/:projectId
551
+ GET /api/ota/react-native/:projectId
552
+ GET /api/ota/assets/:projectId/:updateId/:assetName
553
+ ```
554
+
555
+ Expo manifest request:
556
+
557
+ - Read `expo-runtime-version`.
558
+ - Read `expo-platform`.
559
+ - Read `expo-channel-name` or configured request header.
560
+ - Query `ChannelRuntimeIndex`.
561
+ - Apply rollout gating.
562
+ - Return Expo Updates protocol response.
563
+ - Return `204` if no update is available.
564
+
565
+ Vanilla RN manifest request:
566
+
567
+ - Read app id, channel, runtime version, platform, current update id.
568
+ - Query the same update table.
569
+ - Return Mach RN manifest.
570
+ - Return no-op when already current.
571
+
572
+ Important fix:
573
+
574
+ - Never point `manifestKey` to a zip.
575
+ - Store and serve a real manifest JSON.
576
+ - Store bundle/assets separately.
577
+
578
+ ## Expo Engine
579
+
580
+ Engine id:
581
+
582
+ ```text
583
+ expo-updates
584
+ ```
585
+
586
+ Setup:
587
+
588
+ 1. Ensure project has `expo-updates`.
589
+ 2. Configure Expo app with Mach update URL.
590
+ 3. Configure runtime version.
591
+ 4. Configure request headers for channel if needed.
592
+ 5. Add/use `@radhya/mach/expo-plugin` when possible.
593
+ 6. Avoid writing any EAS Update URL.
594
+ 7. Warn when existing `updates.url` points to `https://u.expo.dev`.
595
+ 8. Ask before replacing the URL, unless `--yes`.
596
+
597
+ Expo config plugin should apply:
598
+
599
+ ```js
600
+ config.updates = {
601
+ ...(config.updates || {}),
602
+ url: `https://mach-api.securejs.in/api/ota/expo/${projectId}`,
603
+ checkAutomatically: ota.checkAutomatically || 'ON_LOAD',
604
+ fallbackToCacheTimeout: ota.fallbackToCacheTimeout || 0,
605
+ }
606
+ config.runtimeVersion = resolvedRuntimeVersion
607
+ ```
608
+
609
+ Publishing:
610
+
611
+ 1. Resolve project/channel/platform/profile/runtime.
612
+ 2. Run native-change guard.
613
+ 3. Export bundles/assets with Expo tooling.
614
+ 4. Convert export output into Expo Updates manifest.
615
+ 5. Upload launch asset and assets individually.
616
+ 6. Save manifest JSON.
617
+ 7. Publish update metadata.
618
+ 8. Print dashboard link and next verification command.
619
+
620
+ Open implementation question:
621
+
622
+ - Validate exact Expo export output across current SDK versions before coding. The implementation must parse the actual export metadata rather than assume folder shape.
623
+
624
+ ## Vanilla React Native Engine
625
+
626
+ Engine id:
627
+
628
+ ```text
629
+ mach-react-native
630
+ ```
631
+
632
+ Setup:
633
+
634
+ 1. Install or verify a Mach RN OTA client package.
635
+ 2. Patch iOS native loader to ask Mach client for a JS bundle path before falling back to embedded bundle.
636
+ 3. Patch Android native loader similarly.
637
+ 4. Store Mach update URL, channel, runtime version, and app id in native config.
638
+ 5. Do not require Expo.
639
+
640
+ Possible package names:
641
+
642
+ ```text
643
+ @radhya/mach-react-native
644
+ @radhya/mach-ota-react-native
645
+ ```
646
+
647
+ The client package should support:
648
+
649
+ - check for update
650
+ - download update
651
+ - install on next restart
652
+ - reload now
653
+ - rollback to previous working update
654
+ - mandatory update handling
655
+ - current update id reporting
656
+ - binary embedded fallback
657
+
658
+ Publishing:
659
+
660
+ ```bash
661
+ npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output build/mach-ota/ios/main.jsbundle --assets-dest build/mach-ota/ios/assets
662
+ npx react-native bundle --platform android --dev false --entry-file index.js --bundle-output build/mach-ota/android/index.android.bundle --assets-dest build/mach-ota/android/res
663
+ ```
664
+
665
+ The exact command should use project config:
666
+
667
+ - `ota.reactNative.entryFile`
668
+ - custom Metro config when present
669
+ - platform selection
670
+ - Hermes bytecode decision later
671
+
672
+ Manifest shape:
673
+
674
+ ```json
675
+ {
676
+ "id": "update-id",
677
+ "platform": "ios",
678
+ "channel": "production",
679
+ "runtimeVersion": "runtime",
680
+ "mandatory": false,
681
+ "rollout": 100,
682
+ "createdAt": "2026-07-11T00:00:00.000Z",
683
+ "bundle": {
684
+ "url": "https://mach-api.securejs.in/api/ota/assets/...",
685
+ "hash": "sha256"
686
+ },
687
+ "assets": [
688
+ {
689
+ "url": "https://mach-api.securejs.in/api/ota/assets/...",
690
+ "hash": "sha256",
691
+ "path": "assets/image.png"
692
+ }
693
+ ]
694
+ }
695
+ ```
696
+
697
+ ## CodePush Compatibility
698
+
699
+ Do not make CodePush the core.
700
+
701
+ Later, add optional compatibility:
702
+
703
+ ```json
704
+ {
705
+ "ota": {
706
+ "engine": "codepush-compatible"
707
+ }
708
+ }
709
+ ```
710
+
711
+ This can help existing vanilla RN users migrate, but it should adapt Mach's update records to a CodePush-like client protocol. It should not depend on App Center service.
712
+
713
+ ## Rollout Logic
714
+
715
+ Rollout should be deterministic per installation.
716
+
717
+ Inputs:
718
+
719
+ ```text
720
+ projectId
721
+ channel
722
+ updateId
723
+ installationId
724
+ ```
725
+
726
+ Algorithm:
727
+
728
+ 1. Hash inputs.
729
+ 2. Convert hash to bucket 0-99.
730
+ 3. Serve update if bucket is less than rollout percentage.
731
+
732
+ The client needs a stable anonymous installation id. For Expo, use headers if practical or store in app code with `expo-updates` request headers later. For initial implementation, rollout may be server-side by device id only for Mach RN and disabled for Expo until stable headers are solved.
733
+
734
+ ## Rollback
735
+
736
+ Expo rollback:
737
+
738
+ - Prefer serving a directive compatible with Expo Updates protocol for rollback-to-embedded when rolling back all updates for a runtime.
739
+ - For normal rollback to a previous update, mark newer update disabled and serve the previous active update.
740
+
741
+ Vanilla RN rollback:
742
+
743
+ - Mark update disabled.
744
+ - Client should keep previous working update locally.
745
+ - Server can return previous active update or no-op.
746
+
747
+ Dashboard rollback should:
748
+
749
+ 1. Show current active update.
750
+ 2. Show compatible previous updates.
751
+ 3. Confirm affected channel/platform/runtime.
752
+ 4. Record who performed rollback.
753
+
754
+ ## Security
755
+
756
+ Initial:
757
+
758
+ - Only authenticated developers can publish updates.
759
+ - Client manifest routes are public but project-scoped.
760
+ - Asset URLs are immutable and can be public or signed.
761
+ - Never expose secrets in manifests.
762
+
763
+ Phase 2:
764
+
765
+ - Add manifest code signing.
766
+ - Store public certificate in app config.
767
+ - Store private signing key in Mach secrets.
768
+ - Sign manifest/directives on API or CLI.
769
+ - Verify signatures in Expo when configured.
770
+ - Add Mach RN client signature verification.
771
+
772
+ ## Dashboard Plan
773
+
774
+ Add an OTA tab or project subpage:
775
+
776
+ ```text
777
+ Project -> OTA Updates
778
+ ```
779
+
780
+ Views:
781
+
782
+ - Channels.
783
+ - Active update per channel/platform/runtime.
784
+ - Update history.
785
+ - Runtime versions.
786
+ - Rollout percentage.
787
+ - Publish source: CLI user, commit, branch.
788
+ - Rollback/promote actions.
789
+ - Setup instructions.
790
+
791
+ Build detail page:
792
+
793
+ - Show runtime versions generated by the build.
794
+ - Show whether OTA is enabled for that build.
795
+ - Show compatible update channel.
796
+
797
+ Docs:
798
+
799
+ - Add a full OTA docs page.
800
+ - Explain that Mach does not use EAS Update.
801
+ - Explain Expo custom server setup.
802
+ - Explain vanilla RN Mach client setup.
803
+ - Explain runtime version and native-change guard.
804
+ - Explain cost model.
805
+
806
+ ## Cost Plan
807
+
808
+ No new fixed-cost service is required for v1.
809
+
810
+ Use:
811
+
812
+ - existing Mach API
813
+ - Cloudflare R2 for OTA bundles/assets/manifests
814
+ - DynamoDB on-demand table
815
+ - existing dashboard
816
+
817
+ Avoid:
818
+
819
+ - EAS Update
820
+ - App Center
821
+ - new always-on worker
822
+ - proxying OTA asset downloads through Mach API
823
+
824
+ Controls:
825
+
826
+ - Store immutable assets once by hash.
827
+ - Reuse identical assets across updates later.
828
+ - Retention policy for inactive updates.
829
+ - Compression for JS bundles.
830
+ - Warn about large assets.
831
+ - Dashboard storage usage per project.
832
+
833
+ ## Implementation Files
834
+
835
+ CLI:
836
+
837
+ ```text
838
+ src/commands/ota.ts
839
+ src/lib/ota/types.ts
840
+ src/lib/ota/config.ts
841
+ src/lib/ota/detect.ts
842
+ src/lib/ota/runtime.ts
843
+ src/lib/ota/native-fingerprint.ts
844
+ src/lib/ota/verify.ts
845
+ src/lib/ota/engines/index.ts
846
+ src/lib/ota/engines/expo-updates.ts
847
+ src/lib/ota/engines/mach-react-native.ts
848
+ src/lib/ota/expo-plugin.ts
849
+ ```
850
+
851
+ Packaged Expo plugin:
852
+
853
+ ```text
854
+ expo-plugin/index.js
855
+ ```
856
+
857
+ Add OTA behavior to the existing plugin or split:
858
+
859
+ ```text
860
+ @radhya/mach/expo-plugin
861
+ ```
862
+
863
+ The plugin can configure both deep links and OTA from `mach.config.json`.
864
+
865
+ API:
866
+
867
+ ```text
868
+ api/src/index.ts
869
+ api/scripts/create-updates-table.js
870
+ ```
871
+
872
+ Dashboard:
873
+
874
+ ```text
875
+ app/(tabs)/ota.tsx
876
+ app/(tabs)/project/[id].tsx
877
+ app/(tabs)/build/[id].tsx
878
+ app/(tabs)/docs.tsx
879
+ ```
880
+
881
+ Docs:
882
+
883
+ ```text
884
+ docs/ota-implementation-plan.md
885
+ docs/ota.md
886
+ ```
887
+
888
+ ## Phased Delivery
889
+
890
+ ### Phase 0: Disable Broken Path Safely
891
+
892
+ - Keep `mach update` available, but do not let it pretend success if the API cannot serve the update.
893
+ - Add warning that current OTA is experimental until `mach ota` is ready.
894
+ - Avoid publishing zip-as-manifest records.
895
+
896
+ ### Phase 1: Core Model And API
897
+
898
+ - Create `MachUpdates` table script with query-friendly indexes.
899
+ - Add project-scoped publish/list/get routes.
900
+ - Add R2 upload URLs for OTA objects.
901
+ - Store real manifest metadata.
902
+ - Add project-scoped Expo manifest route.
903
+ - Return `204` for no update.
904
+
905
+ ### Phase 2: Runtime Version And Build Integration
906
+
907
+ - Add runtime version computation to Mach build.
908
+ - Store runtime versions and native fingerprints on build records.
909
+ - Add native fingerprint helper in CLI.
910
+ - Add latest-compatible-build resolution.
911
+ - Add native-change guard.
912
+
913
+ ### Phase 3: Expo OTA
914
+
915
+ - Add `mach ota setup` for Expo.
916
+ - Configure `expo-updates` to use Mach URL.
917
+ - Add `ota` config writing and migration from legacy `update`.
918
+ - Add Expo export parsing.
919
+ - Generate valid Expo manifest.
920
+ - Upload individual immutable assets.
921
+ - Publish and verify.
922
+ - Confirm updates do not appear in Expo dashboard because `eas update` is never called.
923
+
924
+ ### Phase 4: Vanilla React Native OTA
925
+
926
+ - Create Mach RN OTA client package.
927
+ - Add `mach ota setup` native patching for iOS and Android.
928
+ - Add `react-native bundle` export engine.
929
+ - Add Mach RN manifest route.
930
+ - Add install/reload/rollback client behavior.
931
+ - Verify on real iOS/Android app.
932
+
933
+ ### Phase 5: Dashboard
934
+
935
+ - Add OTA update list page.
936
+ - Add rollback/promote actions.
937
+ - Add runtime compatibility display.
938
+ - Add docs.
939
+
940
+ ### Phase 6: Rollout, Promotion, Signing, Retention
941
+
942
+ - Add rollout percentage.
943
+ - Add channel promotion.
944
+ - Add code signing.
945
+ - Add retention cleanup.
946
+ - Add asset dedupe.
947
+
948
+ ## Acceptance Tests
949
+
950
+ Expo:
951
+
952
+ - `mach ota setup` updates Mach config and Expo plugin without EAS URL.
953
+ - Existing `updates.url` pointing to `u.expo.dev` is detected and replaced only with confirmation.
954
+ - `mach ota publish --channel staging` uploads real manifest and assets.
955
+ - Expo client requests Mach endpoint and receives valid Expo Updates manifest.
956
+ - Expo dashboard does not show the update.
957
+ - Native dependency change blocks OTA.
958
+
959
+ Vanilla RN:
960
+
961
+ - `mach ota setup` installs Mach RN client and patches native code.
962
+ - `mach ota publish --platform ios` runs RN bundler and uploads iOS bundle/assets.
963
+ - `mach ota publish --platform android` runs RN bundler and uploads Android bundle/assets.
964
+ - Client downloads update, restarts, and runs new JS.
965
+ - Bad update rolls back to previous working bundle.
966
+ - Native change blocks OTA.
967
+
968
+ API:
969
+
970
+ - Updates are scoped by project.
971
+ - Latest active update is returned for matching project/channel/platform/runtime.
972
+ - No matching update returns no-op/204.
973
+ - Disabled update is not served.
974
+ - Rollback serves previous active update.
975
+
976
+ Dashboard:
977
+
978
+ - Update appears in project OTA history.
979
+ - Runtime version is visible.
980
+ - Rollback action changes served update.
981
+ - Cost/storage data is visible or planned clearly.
982
+
983
+ ## Open Questions
984
+
985
+ - Should the first Mach RN client use JSI/TurboModule, classic native module, or a minimal native bridge?
986
+ - Should Expo rollout wait until stable installation-id request headers are implemented?
987
+ - Should OTA assets be public immutable URLs or signed API URLs in v1?
988
+ - Should code signing be mandatory for production channels from day one?
989
+ - Should `ota.runtimeVersionPolicy` default to `nativeFingerprint` for all projects, or `latestBuild` after the first successful Mach build?
990
+ - Should `mach build` automatically create/update OTA channel metadata when `ota.enabled` is true?
991
+
992
+ ## Recommended First Implementation Slice
993
+
994
+ Do not start with vanilla RN client code first. Start with the shared core.
995
+
996
+ 1. Add `mach ota` namespace and make `mach update` a deprecated alias.
997
+ 2. Add `ota` config type and migration from `update`.
998
+ 3. Add runtime version and native fingerprint helpers.
999
+ 4. Add `MachUpdates` table script and project-scoped API.
1000
+ 5. Fix Expo path end to end because `expo-updates` already provides the native client.
1001
+ 6. Add dashboard docs and update history.
1002
+ 7. Then implement Mach RN client for vanilla React Native.
1003
+
1004
+ This keeps the first usable release smaller, fixes the broken current path, avoids new cost, and leaves a clean adapter boundary for vanilla React Native.