From 7a25b7129652dbb0de0fd86aed4c6006b1710657 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 12 Oct 2025 04:42:30 +0000 Subject: [PATCH 01/10] feat: Implement OTA update system This commit introduces a comprehensive Over-the-Air (OTA) update system for the React Native application. The system is composed of two main parts: 1. A CI/CD publisher using GitHub Actions to build and publish JS bundles and assets as public OCI images to the GitHub Container Registry (GHCR). 2. A native client module, `UpdateManager`, for both iOS and Android, which handles the discovery, download, activation, and robust rollback of updates. Key features include: - Automated builds and publishing for release tags and pull requests. - Dynamic OCI image tagging based on native compatibility version. - Native module API for JS to interact with the update system. - Robust crash detection and rollback strategy, including cross-channel fallback to the 'release' channel. --- .github/workflows/publish-ota-update.yml | 59 ++++++ NATIVE_VERSION | 1 + .../com/allaboutolaf/MainApplication.java | 103 ++++++++++ ios/AllAboutOlaf/AppDelegate.mm | 102 +++++++++- .../com/allaboutolaf/UpdateManagerModule.java | 177 +++++++++++++++++ .../allaboutolaf/UpdateManagerPackage.java | 27 +++ modules/UpdateManager/ios/UpdateManager.m | 20 ++ modules/UpdateManager/ios/UpdateManager.swift | 187 ++++++++++++++++++ package.json | 7 +- 9 files changed, 675 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/publish-ota-update.yml create mode 100644 NATIVE_VERSION create mode 100644 modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerModule.java create mode 100644 modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerPackage.java create mode 100644 modules/UpdateManager/ios/UpdateManager.m create mode 100644 modules/UpdateManager/ios/UpdateManager.swift diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml new file mode 100644 index 0000000000..5124d570c7 --- /dev/null +++ b/.github/workflows/publish-ota-update.yml @@ -0,0 +1,59 @@ +name: Publish OTA Update + +on: + workflow_dispatch: + push: + tags: + - 'v*.*.*' + pull_request: + +permissions: + packages: write + +jobs: + build_and_publish: + name: Build and Publish OTA Update + runs-on: ubuntu-latest + strategy: + matrix: + platform: [ios, android] + + steps: + - name: Check out repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version-file: '.node-version' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build OTA update + run: npm run build:ota -- --platform ${{ matrix.platform }} + + - name: Generate image tag + id: tag + run: | + NATIVE_COMPAT_VERSION=$(cat NATIVE_VERSION) + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "tag=pr-${{ github.event.pull_request.number }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.ref_name }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT + fi + + - name: Publish to GHCR + uses: docker/build-push-action@v4 + with: + context: ./dist/ota + push: true + tags: ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }} + labels: org.opencontainers.image.source=${{ github.repositoryUrl }} + # The following two lines make the image public + # See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#ensuring-workflow-access-to-your-package + # and: https://github.com/docker/build-push-action/issues/294 + # We're not using login-action as we have the permissions set at the workflow level + # All we need to do is set the actor to the GITHUB_TOKEN user + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/NATIVE_VERSION b/NATIVE_VERSION new file mode 100644 index 0000000000..afaf360d37 --- /dev/null +++ b/NATIVE_VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/android/app/src/main/java/com/allaboutolaf/MainApplication.java b/android/app/src/main/java/com/allaboutolaf/MainApplication.java index 783cc2fd13..2292cafc98 100644 --- a/android/app/src/main/java/com/allaboutolaf/MainApplication.java +++ b/android/app/src/main/java/com/allaboutolaf/MainApplication.java @@ -38,6 +38,109 @@ protected String getJSMainModuleName() { return "index"; } + @Override + protected String getJSBundleFile() { + android.content.SharedPreferences prefs = getApplication().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE); + String pendingBundlePath = prefs.getString("jsBundlePath_pending", null); + + // 1. Activate new update if one is pending + if (pendingBundlePath != null) { + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.putString("jsBundlePath", pendingBundlePath); + editor.putBoolean("isUpdatePendingVerification", true); + editor.remove("jsBundlePath_pending"); + + // Also save the version tag of the new update + java.io.File pendingBundle = new java.io.File(pendingBundlePath); + String versionTag = pendingBundle.getParentFile().getParentFile().getName(); + editor.putString("currentJSVersion", versionTag); + + editor.apply(); + } + + // 2. Check if the last update caused a crash + if (prefs.getBoolean("isUpdatePendingVerification", false)) { + // Crash detected! Start rollback. + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.remove("isUpdatePendingVerification"); + + String currentBundlePath = prefs.getString("jsBundlePath", null); + if (currentBundlePath != null) { + java.io.File currentBundle = new java.io.File(currentBundlePath); + java.io.File updateDir = currentBundle.getParentFile().getParentFile(); + + // Delete the faulty update + deleteRecursively(updateDir); + + // Find the next best update + String channel = prefs.getString("updateChannel", "release"); + java.io.File channelDir = new java.io.File(getApplication().getFilesDir(), "updates/" + channel); + java.io.File[] availableUpdates = getSortedUpdatesInDirectory(channelDir); + + boolean foundViableUpdate = false; + for (int i = 0; i < Math.min(3, availableUpdates.length); i++) { + java.io.File nextBestUpdate = availableUpdates[i]; + String nextBestBundlePath = new java.io.File(nextBestUpdate, "bundles/main.jsbundle").getAbsolutePath(); + + if (new java.io.File(nextBestBundlePath).exists()) { + editor.putString("jsBundlePath", nextBestBundlePath); + foundViableUpdate = true; + break; + } + } + + if (!foundViableUpdate) { + editor.remove("jsBundlePath"); + + // Cross-channel fallback + if (!channel.equals("release")) { + deleteRecursively(channelDir); + editor.remove("updateChannel"); + + java.io.File releaseChannelDir = new java.io.File(getApplication().getFilesDir(), "updates/release"); + java.io.File[] releaseUpdates = getSortedUpdatesInDirectory(releaseChannelDir); + + if (releaseUpdates.length > 0) { + java.io.File latestReleaseUpdate = releaseUpdates[0]; + String latestReleaseBundlePath = new java.io.File(latestReleaseUpdate, "bundles/main.jsbundle").getAbsolutePath(); + editor.putString("jsBundlePath", latestReleaseBundlePath); + } + } + } + } + editor.apply(); + } + + // 3. Determine the final path + String jsBundlePath = prefs.getString("jsBundlePath", null); + if (jsBundlePath != null) { + java.io.File file = new java.io.File(jsBundlePath); + if (file.exists()) { + return jsBundlePath; + } + } + + return super.getJSBundleFile(); + } + + private java.io.File[] getSortedUpdatesInDirectory(java.io.File directory) { + java.io.File[] files = directory.listFiles(); + if (files == null) { + return new java.io.File[0]; + } + java.util.Arrays.sort(files, (f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified())); + return files; + } + + private void deleteRecursively(java.io.File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (java.io.File child : fileOrDirectory.listFiles()) { + deleteRecursively(child); + } + } + fileOrDirectory.delete(); + } + @Override protected boolean isNewArchEnabled() { return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; diff --git a/ios/AllAboutOlaf/AppDelegate.mm b/ios/AllAboutOlaf/AppDelegate.mm index 0aa528e550..c7ea89831e 100644 --- a/ios/AllAboutOlaf/AppDelegate.mm +++ b/ios/AllAboutOlaf/AppDelegate.mm @@ -22,11 +22,103 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge { -#if DEBUG - return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; -#else - return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; -#endif + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + NSString *pendingBundlePath = [defaults stringForKey:@"jsBundlePath_pending"]; + + // 1. Activate new update if one is pending + if (pendingBundlePath != nil) { + [defaults setObject:pendingBundlePath forKey:@"jsBundlePath"]; + [defaults setBool:YES forKey:@"isUpdatePendingVerification"]; + [defaults removeObjectForKey:@"jsBundlePath_pending"]; + + // Also save the version tag of the new update + NSURL *pendingBundleURL = [NSURL fileURLWithPath:pendingBundlePath]; + NSURL *updateDirURL = [[pendingBundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent]; + NSString *versionTag = [updateDirURL lastPathComponent]; + [defaults setObject:versionTag forKey:@"currentJSVersion"]; + } + + // 2. Check if the last update caused a crash + if ([defaults boolForKey:@"isUpdatePendingVerification"]) { + // Crash detected! Start rollback. + [defaults removeObjectForKey:@"isUpdatePendingVerification"]; // Assume failure until proven otherwise + + NSString *currentBundlePath = [defaults stringForKey:@"jsBundlePath"]; + NSURL *currentBundleURL = [NSURL fileURLWithPath:currentBundlePath]; + NSURL *updateDirURL = [[currentBundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent]; + + // Delete the faulty update + [[NSFileManager defaultManager] removeItemAtURL:updateDirURL error:nil]; + + // Find the next best update + NSString *channel = [defaults stringForKey:@"updateChannel"] ?: @"release"; + NSURL *channelDirURL = [[self getUpdatesDirectory] URLByAppendingPathComponent:channel]; + + NSArray *availableUpdates = [self getSortedUpdatesInDirectory:channelDirURL]; + + // Try up to 3 historical updates + BOOL foundViableUpdate = NO; + for (int i = 0; i < MIN(3, [availableUpdates count]); i++) { + NSURL *nextBestUpdateURL = [availableUpdates objectAtIndex:i]; + NSString *nextBestBundlePath = [[nextBestUpdateURL URLByAppendingPathComponent:@"bundles"] URLByAppendingPathComponent:@"main.jsbundle"].path; + + if ([[NSFileManager defaultManager] fileExistsAtPath:nextBestBundlePath]) { + [defaults setObject:nextBestBundlePath forKey:@"jsBundlePath"]; + foundViableUpdate = YES; + break; + } + } + + if (!foundViableUpdate) { + [defaults removeObjectForKey:@"jsBundlePath"]; + + // Cross-channel fallback + if (![channel isEqualToString:@"release"]) { + [[NSFileManager defaultManager] removeItemAtURL:channelDirURL error:nil]; + [defaults removeObjectForKey:@"updateChannel"]; + + NSURL *releaseChannelDirURL = [[self getUpdatesDirectory] URLByAppendingPathComponent:@"release"]; + NSArray *releaseUpdates = [self getSortedUpdatesInDirectory:releaseChannelDirURL]; + + if ([releaseUpdates count] > 0) { + NSURL *latestReleaseUpdateURL = [releaseUpdates firstObject]; + NSString *latestReleaseBundlePath = [[latestReleaseUpdateURL URLByAppendingPathComponent:@"bundles"] URLByAppendingPathComponent:@"main.jsbundle"].path; + [defaults setObject:latestReleaseBundlePath forKey:@"jsBundlePath"]; + } + } + } + } + + // 3. Determine the final URL + NSString *jsBundlePath = [defaults stringForKey:@"jsBundlePath"]; + if (jsBundlePath != nil && [[NSFileManager defaultManager] fileExistsAtPath:jsBundlePath]) { + return [NSURL fileURLWithPath:jsBundlePath]; + } else { + #if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; + #else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; + #endif + } +} + +- (NSURL *)getUpdatesDirectory { + return [[[[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask] firstObject] URLByAppendingPathComponent:@"updates"]; +} + +- (NSArray *)getSortedUpdatesInDirectory:(NSURL *)directoryURL { + NSArray *properties = @[NSURLContentModificationDateKey]; + NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:directoryURL + includingPropertiesForKeys:properties + options:NSDirectoryEnumerationSkipsHiddenFiles + error:nil]; + + return [contents sortedArrayUsingComparator:^NSComparisonResult(NSURL *url1, NSURL *url2) { + NSDate *date1, *date2; + [url1 getResourceValue:&date1 forKey:NSURLContentModificationDateKey error:nil]; + [url2 getResourceValue:&date2 forKey:NSURLContentModificationDateKey error:nil]; + return [date2 compare:date1]; // Descending order + }]; } @end diff --git a/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerModule.java b/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerModule.java new file mode 100644 index 0000000000..b4a1b9be06 --- /dev/null +++ b/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerModule.java @@ -0,0 +1,177 @@ +// UpdateManagerModule.java +package com.allaboutolaf; + +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; + +public class UpdateManagerModule extends ReactContextBaseJavaModule { + UpdateManagerModule(ReactApplicationContext context) { + super(context); + } + + @Override + public String getName() { + return "UpdateManager"; + } + + @ReactMethod + public void getAvailableUpdates(Promise promise) { + try { + String nativeVersion = BuildConfig.VERSION_NAME; + android.content.SharedPreferences prefs = getReactApplicationContext().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE); + String channel = prefs.getString("updateChannel", "release"); + String platform = "android"; + String currentJSVersion = prefs.getString("currentJSVersion", "0.0.0"); + + java.net.URL url = new java.net.URL("https://ghcr.io/v2/all-about-olaf/all-about-olaf/tags/list"); + java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + + java.io.InputStream inputStream = connection.getInputStream(); + java.util.Scanner scanner = new java.util.Scanner(inputStream).useDelimiter("\\A"); + String response = scanner.hasNext() ? scanner.next() : ""; + + org.json.JSONObject json = new org.json.JSONObject(response); + org.json.JSONArray tags = json.getJSONArray("tags"); + + com.facebook.react.bridge.WritableArray filteredTags = com.facebook.react.bridge.Arguments.createArray(); + for (int i = 0; i < tags.length(); i++) { + String tag = tags.getString(i); + String[] components = tag.split("-"); + if (components.length == 3) { + String tagVersion = components[0]; + String tagNativeVersion = components[1]; + String tagPlatform = components[2]; + + boolean channelMatches; + if (channel.equals("release")) { + channelMatches = tagVersion.startsWith("v"); + } else { + channelMatches = tagVersion.startsWith(channel); + } + + if (tagPlatform.equals(platform) && + tagNativeVersion.equals(nativeVersion) && + channelMatches && + isSemanticallyNewer(tagVersion, currentJSVersion)) { + filteredTags.pushString(tag); + } + } + } + promise.resolve(filteredTags); + + } catch (Exception e) { + promise.reject("E_UPDATE_DISCOVERY_FAILED", e); + } + } + + private boolean isSemanticallyNewer(String newVersion, String currentVersion) { + // Simplified semver compare, in a real app use a proper library + return newVersion.compareTo(currentVersion) > 0; + } + + @ReactMethod + public void downloadUpdate(String tag, Promise promise) { + new Thread(() -> { + try { + android.content.SharedPreferences prefs = getReactApplicationContext().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE); + String channel = prefs.getString("updateChannel", "release"); + + java.io.File updatesDir = new java.io.File(getReactApplicationContext().getFilesDir(), "updates/" + channel); + if (!updatesDir.exists()) { + updatesDir.mkdirs(); + } + java.io.File updateDir = new java.io.File(updatesDir, tag); + + // 1. Fetch Manifest + java.net.URL manifestUrl = new java.net.URL("https://ghcr.io/v2/all-about-olaf/all-about-olaf/manifests/" + tag); + java.net.HttpURLConnection manifestConnection = (java.net.HttpURLConnection) manifestUrl.openConnection(); + manifestConnection.setRequestProperty("Accept", "application/vnd.oci.image.manifest.v1+json"); + manifestConnection.setRequestMethod("GET"); + + String manifestResponse = new java.util.Scanner(manifestConnection.getInputStream()).useDelimiter("\\A").next(); + org.json.JSONObject manifestJson = new org.json.JSONObject(manifestResponse); + String digest = manifestJson.getJSONArray("layers").getJSONObject(0).getString("digest"); + + // 2. Download Blob + java.net.URL blobUrl = new java.net.URL("https://ghcr.io/v2/all-about-olaf/all-about-olaf/blobs/" + digest); + java.net.HttpURLConnection blobConnection = (java.net.HttpURLConnection) blobUrl.openConnection(); + java.io.InputStream inputStream = blobConnection.getInputStream(); + + if (!updateDir.exists()) { + updateDir.mkdirs(); + } + java.io.File destinationFile = new java.io.File(updateDir, "update.tar.gz"); + java.io.FileOutputStream outputStream = new java.io.FileOutputStream(destinationFile); + + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.close(); + inputStream.close(); + + // Extract the archive + Process process = Runtime.getRuntime().exec("tar -xzf " + destinationFile.getAbsolutePath() + " -C " + updateDir.getAbsolutePath()); + process.waitFor(); + destinationFile.delete(); + + // 3. Cleanup old updates + cleanupOldUpdates(updatesDir); + + // 4. Save path for activation + String bundlePath = new java.io.File(updateDir, "bundles/main.jsbundle").getAbsolutePath(); + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.putString("jsBundlePath_pending", bundlePath); + editor.apply(); + + promise.resolve(bundlePath); + + } catch (Exception e) { + promise.reject("E_DOWNLOAD_FAILED", e); + } + }).start(); + } + + private void cleanupOldUpdates(java.io.File channelDir) { + java.io.File[] updateDirs = channelDir.listFiles(); + if (updateDirs != null && updateDirs.length > 3) { + java.util.Arrays.sort(updateDirs, (f1, f2) -> Long.compare(f1.lastModified(), f2.lastModified())); + for (int i = 0; i < updateDirs.length - 3; i++) { + deleteRecursively(updateDirs[i]); + } + } + } + + private void deleteRecursively(java.io.File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) { + for (java.io.File child : fileOrDirectory.listFiles()) { + deleteRecursively(child); + } + } + fileOrDirectory.delete(); + } + + @ReactMethod + public void setUpdateChannel(String channelName, Promise promise) { + android.content.SharedPreferences prefs = getReactApplicationContext().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.putString("updateChannel", channelName); + editor.apply(); + promise.resolve(null); + } + + @ReactMethod + public void markUpdateAsGood(Promise promise) { + android.content.SharedPreferences prefs = getReactApplication-Context().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.remove("isUpdatePendingVerification"); + editor.apply(); + promise.resolve(null); + } +} diff --git a/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerPackage.java b/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerPackage.java new file mode 100644 index 0000000000..8900d4ef4e --- /dev/null +++ b/modules/UpdateManager/android/src.main/java/com/allaboutolaf/UpdateManagerPackage.java @@ -0,0 +1,27 @@ +// UpdateManagerPackage.java +package com.allaboutolaf; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class UpdateManagerPackage implements ReactPackage { + + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createNativeModules( + ReactApplicationContext reactContext) { + List modules = new ArrayList<>(); + modules.add(new UpdateManagerModule(reactContext)); + return modules; + } +} diff --git a/modules/UpdateManager/ios/UpdateManager.m b/modules/UpdateManager/ios/UpdateManager.m new file mode 100644 index 0000000000..c3eebeef29 --- /dev/null +++ b/modules/UpdateManager/ios/UpdateManager.m @@ -0,0 +1,20 @@ +// UpdateManager.m +#import + +@interface RCT_EXTERN_MODULE(UpdateManager, NSObject) + +RCT_EXTERN_METHOD(getAvailableUpdates:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(downloadUpdate:(NSString *)tag + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(setUpdateChannel:(NSString *)channelName + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(markUpdateAsGood:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/modules/UpdateManager/ios/UpdateManager.swift b/modules/UpdateManager/ios/UpdateManager.swift new file mode 100644 index 0000000000..849a99f47f --- /dev/null +++ b/modules/UpdateManager/ios/UpdateManager.swift @@ -0,0 +1,187 @@ +// UpdateManager.swift +import Foundation + +@objc(UpdateManager) +class UpdateManager: NSObject { + + @objc + static func requiresMainQueueSetup() -> Bool { + return true + } + + @objc(getAvailableUpdates:rejecter:) + func getAvailableUpdates(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let nativeVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String + let channel = UserDefaults.standard.string(forKey: "updateChannel") ?? "release" + let platform = "ios" + + let currentJSVersion = UserDefaults.standard.string(forKey: "currentJSVersion") ?? "0.0.0" + + let url = URL(string: "https://ghcr.io/v2/all-about-olaf/all-about-olaf/tags/list")! + + let task = URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data, error == nil else { + reject("E_NETWORK_ERROR", "Failed to fetch tags", error) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let tags = json["tags"] as? [String] { + + let filteredTags = tags.filter { tag in + let components = tag.split(separator: "-") + if components.count != 3 { return false } + let tagVersion = String(components[0]) + let tagNativeVersion = String(components[1]) + let tagPlatform = String(components[2]) + + let channelMatches: Bool + if channel == "release" { + channelMatches = tagVersion.starts(with: "v") + } else { + channelMatches = tagVersion.starts(with: channel) + } + + return tagPlatform == platform && + tagNativeVersion == nativeVersion && + channelMatches && + tagVersion.compare(currentJSVersion, options: .numeric) == .orderedDescending + } + resolve(filteredTags) + } else { + reject("E_INVALID_RESPONSE", "Invalid response from GHCR", nil) + } + } catch { + reject("E_JSON_PARSE_ERROR", "Failed to parse JSON", error) + } + } + task.resume() + } + + @objc(downloadUpdate:resolver:rejecter:) + func downloadUpdate(tag: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + let channel = UserDefaults.standard.string(forKey: "updateChannel") ?? "release" + let updatesDir = getUpdatesDirectory().appendingPathComponent(channel) + let updateDir = updatesDir.appendingPathComponent(tag) + + // 1. Fetch Manifest + let manifestUrl = URL(string: "https://ghcr.io/v2/all-about-olaf/all-about-olaf/manifests/\(tag)")! + var manifestRequest = URLRequest(url: manifestUrl) + manifestRequest.setValue("application/vnd.oci.image.manifest.v1+json", forHTTPHeaderField: "Accept") + + let manifestTask = URLSession.shared.dataTask(with: manifestRequest) { data, response, error in + guard let data = data, error == nil else { + reject("E_NETWORK_ERROR", "Failed to fetch manifest", error) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let layers = json["layers"] as? [[String: Any]], + let layer = layers.first, + let digest = layer["digest"] as? String { + + // 2. Download Blob + self.downloadBlob(digest: digest, to: updateDir) { error in + if let error = error { + reject("E_DOWNLOAD_FAILED", "Failed to download update", error) + return + } + + // 3. Clean up old updates + self.cleanupOldUpdates(in: updatesDir) + + // 4. Save path for activation + let bundlePath = updateDir.appendingPathComponent("bundles/main.jsbundle").path + UserDefaults.standard.set(bundlePath, forKey: "jsBundlePath_pending") + + resolve(bundlePath) + } + } else { + reject("E_INVALID_MANIFEST", "Invalid manifest file", nil) + } + } catch { + reject("E_JSON_PARSE_ERROR", "Failed to parse manifest JSON", error) + } + } + manifestTask.resume() + } + + private func downloadBlob(digest: String, to destinationDir: URL, completion: @escaping (Error?) -> Void) { + let blobUrl = URL(string: "https://ghcr.io/v2/all-about-olaf/all-about-olaf/blobs/\(digest)")! + + let task = URLSession.shared.downloadTask(with: blobUrl) { localUrl, response, error in + guard let localUrl = localUrl, error == nil else { + completion(error) + return + } + + do { + try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true, attributes: nil) + let destinationFile = destinationDir.appendingPathComponent("update.tar.gz") + try FileManager.default.moveItem(at: localUrl, to: destinationFile) + + // Extract the archive + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["-xzf", destinationFile.path, "-C", destinationDir.path] + + try process.run() + process.waitUntilExit() + + // Clean up the archive + try FileManager.default.removeItem(at: destinationFile) + + completion(nil) + } catch { + completion(error) + } + } + task.resume() + } + + private func getUpdatesDirectory() -> URL { + let fileManager = FileManager.default + let appSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let updatesDir = appSupportURL.appendingPathComponent("updates") + + if !fileManager.fileExists(atPath: updatesDir.path) { + try? fileManager.createDirectory(at: updatesDir, withIntermediateDirectories: true, attributes: nil) + } + + return updatesDir + } + + private func cleanupOldUpdates(in channelDir: URL) { + let fileManager = FileManager.default + do { + let updateDirs = try fileManager.contentsOfDirectory(at: channelDir, includingPropertiesForKeys: [.creationDateKey], options: .skipsHiddenFiles) + + if updateDirs.count > 3 { + let sortedDirs = updateDirs.sorted { + let date1 = (try? $0.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date.distantPast + let date2 = (try? $1.resourceValues(forKeys: [.creationDateKey]))?.creationDate ?? Date.distantPast + return date1.compare(date2) == .orderedAscending + } + + let dirToRemove = sortedDirs.first! + try fileManager.removeItem(at: dirToRemove) + } + } catch { + print("Error cleaning up old updates: \(error)") + } + } + + @objc(setUpdateChannel:resolver:rejecter:) + func setUpdateChannel(channelName: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + UserDefaults.standard.set(channelName, forKey: "updateChannel") + resolve(nil) + } + + @objc(markUpdateAsGood:rejecter:) + func markUpdateAsGood(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + UserDefaults.standard.removeObject(forKey: "isUpdatePendingVerification") + resolve(nil) + } +} diff --git a/package.json b/package.json index d582ada967..f7ab864ca3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "data": "echo 'use the `bundle-data` script instead' && exit 1", "ios": "react-native run-ios", "lint": "eslint --report-unused-disable-directives --cache source/ modules/ scripts/ images/", + "build:ota": "react-native bundle --entry-file index.js --platform \"${npm_config_platform}\" --dev false --bundle-output ./dist/ota/bundles/main.jsbundle --assets-dest ./dist/ota/assets", "prepare": "patch -p0 -Nfsi contrib/*.patch || true", "pretty": "prettier --write '{source,modules,scripts,images,e2e}/**/*.{js,ts,tsx,json}' 'data/**/*.css' '{*,.*}.{yaml,yml,json,json5,js,ts,tsx}'", "p": "pretty-quick", @@ -111,9 +112,12 @@ "@babel/preset-typescript": "7.24.7", "@babel/runtime": "7.25.6", "@jest/globals": "29.7.0", + "@react-native/eslint-config": "0.73.2", + "@react-native/metro-config": "0.72.11", "@sentry/cli": "2.36.1", "@tanstack/eslint-plugin-query": "4.34.1", "@testing-library/react-native": "12.6.1", + "@tsconfig/react-native": "3.0.5", "@types/base-64": "1.0.2", "@types/http-cache-semantics": "4.0.4", "@types/jest": "29.5.12", @@ -127,9 +131,6 @@ "@types/wordwrap": "1.0.3", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", - "@react-native/eslint-config": "0.73.2", - "@react-native/metro-config": "0.72.11", - "@tsconfig/react-native": "3.0.5", "ajv": "8.17.1", "ajv-formats": "3.0.1", "ajv-keywords": "5.1.0", From bdeebc0e6c320fcb5f65080f8527b22240d70470 Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 16:47:09 -0700 Subject: [PATCH 02/10] ensure directory exists for ota update builds --- .github/workflows/publish-ota-update.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 5124d570c7..1c3ea8f3ee 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -32,7 +32,9 @@ jobs: run: npm ci - name: Build OTA update - run: npm run build:ota -- --platform ${{ matrix.platform }} + run: | + mkdir -p dist/ota/bundles + npm run build:ota -- --platform ${{ matrix.platform }} - name: Generate image tag id: tag From 25517d6e1eab7b0ab1456208d5d71477f7c19f2b Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 16:54:02 -0700 Subject: [PATCH 03/10] fix image tag generation for docker build --- .github/workflows/publish-ota-update.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 1c3ea8f3ee..6d5678cdc9 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -40,6 +40,8 @@ jobs: id: tag run: | NATIVE_COMPAT_VERSION=$(cat NATIVE_VERSION) + REPO_LOWERCASE=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "repo=${REPO_LOWERCASE}" >> $GITHUB_OUTPUT if [[ "${{ github.event_name }}" == "pull_request" ]]; then echo "tag=pr-${{ github.event.pull_request.number }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT else @@ -51,7 +53,7 @@ jobs: with: context: ./dist/ota push: true - tags: ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }} + tags: ghcr.io/${{ steps.tag.outputs.repo }}:${{ steps.tag.outputs.tag }} labels: org.opencontainers.image.source=${{ github.repositoryUrl }} # The following two lines make the image public # See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#ensuring-workflow-access-to-your-package From d04310478ffe8ac6b9e9bb2a7be4f8ef4371c54c Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 17:41:23 -0700 Subject: [PATCH 04/10] create dockerfile for ota bundle --- .github/workflows/publish-ota-update.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 6d5678cdc9..aaf0667d18 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -36,6 +36,13 @@ jobs: mkdir -p dist/ota/bundles npm run build:ota -- --platform ${{ matrix.platform }} + - name: Create Dockerfile for OTA bundle + run: | + cat > dist/ota/Dockerfile <<'EOF' + FROM scratch + COPY . / + EOF + - name: Generate image tag id: tag run: | From b0ba82bd97ec8932ec2113de45f72082b07e339c Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 17:51:23 -0700 Subject: [PATCH 05/10] change GitHub token to use GHCR_TOKEN --- .github/workflows/publish-ota-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index aaf0667d18..45e5db5408 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -67,4 +67,4 @@ jobs: # and: https://github.com/docker/build-push-action/issues/294 # We're not using login-action as we have the permissions set at the workflow level # All we need to do is set the actor to the GITHUB_TOKEN user - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GHCR_TOKEN }} From 12272ace9afa3c1ee000eadb0df8240fb75e6adc Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 17:59:37 -0700 Subject: [PATCH 06/10] revert to GitHub token for workflow access --- .github/workflows/publish-ota-update.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 45e5db5408..aaf0667d18 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -67,4 +67,4 @@ jobs: # and: https://github.com/docker/build-push-action/issues/294 # We're not using login-action as we have the permissions set at the workflow level # All we need to do is set the actor to the GITHUB_TOKEN user - github-token: ${{ secrets.GHCR_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} From 705e72521aefa8140964dc43f23f68ee364667d1 Mon Sep 17 00:00:00 2001 From: Drew Volz Date: Sun, 12 Oct 2025 18:23:51 -0700 Subject: [PATCH 07/10] update publish-ota-update.yml with GHCR login --- .github/workflows/publish-ota-update.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index aaf0667d18..06529b0390 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -8,6 +8,7 @@ on: pull_request: permissions: + contents: read packages: write jobs: @@ -55,6 +56,13 @@ jobs: echo "tag=${{ github.ref_name }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT fi + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish to GHCR uses: docker/build-push-action@v4 with: From 97194ee980c3ba788dde442b29c82369819cc576 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Mon, 13 Oct 2025 02:49:55 +0000 Subject: [PATCH 08/10] try enabling buildkit for oci image exports --- .github/workflows/publish-ota-update.yml | 40 +++++++++++++++++------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 06529b0390..9662b4dddb 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -20,15 +20,12 @@ jobs: platform: [ios, android] steps: - - name: Check out repository - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version-file: '.node-version' - cache: 'npm' + - name: Check out the code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: {node-version-file: '.node-version'} + - name: Install dependencies run: npm ci @@ -55,6 +52,20 @@ jobs: else echo "tag=${{ github.ref_name }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT fi + + - name: Get Git commit timestamps + id: git-timestamp + run: echo "timestamp=$(git log -1 --pretty=%ct)" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + env: + DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index + with: + images: ghcr.io/${{ steps.tag.outputs.repo }} + tags: | + type=raw,value=${{ steps.tag.outputs.tag }} - name: Log in to GHCR uses: docker/login-action@v3 @@ -62,14 +73,21 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Publish to GHCR - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 + env: + SOURCE_DATE_EPOCH: ${{ steps.git-timestamp.outputs.timestamp }} with: + enableBuildKit: true + multiPlatform: true context: ./dist/ota push: true - tags: ghcr.io/${{ steps.tag.outputs.repo }}:${{ steps.tag.outputs.tag }} - labels: org.opencontainers.image.source=${{ github.repositoryUrl }} + tags: ${{ steps.meta.outputs.tags }} + annotations: ${{ steps.meta.outputs.annotations }} # The following two lines make the image public # See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#ensuring-workflow-access-to-your-package # and: https://github.com/docker/build-push-action/issues/294 From 1bd67fee829631bac7748712b13c841f14bd4086 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Mon, 13 Oct 2025 03:20:23 +0000 Subject: [PATCH 09/10] remove old flags to build-push-action --- .github/workflows/publish-ota-update.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index 9662b4dddb..cfe5aee71c 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -82,8 +82,6 @@ jobs: env: SOURCE_DATE_EPOCH: ${{ steps.git-timestamp.outputs.timestamp }} with: - enableBuildKit: true - multiPlatform: true context: ./dist/ota push: true tags: ${{ steps.meta.outputs.tags }} From 5421c25e257a5b253410bb4e207938708e224823 Mon Sep 17 00:00:00 2001 From: Hawken Rives Date: Mon, 13 Oct 2025 03:24:41 +0000 Subject: [PATCH 10/10] add a test annotation --- .github/workflows/publish-ota-update.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-ota-update.yml b/.github/workflows/publish-ota-update.yml index cfe5aee71c..3ce5c58194 100644 --- a/.github/workflows/publish-ota-update.yml +++ b/.github/workflows/publish-ota-update.yml @@ -85,7 +85,9 @@ jobs: context: ./dist/ota push: true tags: ${{ steps.meta.outputs.tags }} - annotations: ${{ steps.meta.outputs.annotations }} + annotations: | + manifest:test.annotation=value + ${{ steps.meta.outputs.annotations }} # The following two lines make the image public # See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#ensuring-workflow-access-to-your-package # and: https://github.com/docker/build-push-action/issues/294