diff --git a/content/rn-codepush/migrating-from-expo-ota.md b/content/rn-codepush/migrating-from-expo-ota.md index 56541a75..4cd3cba4 100644 --- a/content/rn-codepush/migrating-from-expo-ota.md +++ b/content/rn-codepush/migrating-from-expo-ota.md @@ -50,7 +50,7 @@ Before starting the migration, it helps to understand how concepts translate bet - A Codemagic account. Sign up at [codemagic.io](https://codemagic.io/signup) if you do not have one. - A **CodePush access token** provided by the Codemagic team. [Request one here](https://codemagic.io/contact-sales/). -{{}} +{{}} **Important:** CodePush updates are managed entirely through the CLI and CI pipelines — not through the Codemagic web UI. Ensure your team is comfortable with CLI-based workflows before starting. {{}} @@ -135,10 +135,11 @@ yarn remove expo-updates Remove the `updates` block and `runtimeVersion` field from your Expo config: {{< highlight diff "style=paraiso-dark">}} - { + { "expo": { "name": "MyApp", - "slug": "my-app", +- "slug": "my-app", ++ "slug": "my-app" - "runtimeVersion": { - "policy": "fingerprint" - }, @@ -151,6 +152,10 @@ Remove the `updates` block and `runtimeVersion` field from your Expo config: } {{< /highlight >}} +{{< notebox >}} +**Note:** JSON does not allow trailing commas. Ensure your `app.json` remains valid JSON after removing the `runtimeVersion` and `updates` blocks. +{{}} + ### 4c. Remove EAS Update native configuration **Android — `AndroidManifest.xml`** @@ -188,6 +193,16 @@ yarn add @code-push-next/react-native-code-push ## Step 6 — Configure native projects +{{< notebox >}} +**Using Continuous Native Generation (CNG)?** +If your project does not have committed `ios/` and `android/` directories — the +default for projects created with `create-expo-app` — skip Steps 6 and 7. +Follow [CNG projects: apps without native folders](#cng-projects-apps-without-ios-and-android-folders) +instead, then continue from Step 8. +If your project **does** commit its native folders (bare React Native or an ejected +Expo project), continue with Step 6 as written. +{{}} + ### iOS — `Info.plist` Add the CodePush server URL and your deployment key to `ios//Info.plist`: @@ -204,8 +219,8 @@ Replace `YOUR_IOS_DEPLOYMENT_KEY` with the Staging key for development builds an ### Android — `android/app/src/main/res/values/strings.xml` {{< highlight xml "style=paraiso-dark">}} -https://codepush.pro/ -YOUR_ANDROID_DEPLOYMENT_KEY +https://codepush.pro/ +YOUR_ANDROID_DEPLOYMENT_KEY {{< /highlight >}} As with iOS, use your Staging key for debug/QA builds and your Production key for release builds. @@ -230,7 +245,7 @@ cd ios && pod install && cd .. CodePush needs to intercept the JS bundle URL so it can serve updated bundles. This requires a small change to your native app delegate. -### iOS — Objective-C (`AppDelegate.m`) +### iOS — Objective-C (`AppDelegate.mm`) Add the import at the top of the file: @@ -238,16 +253,16 @@ Add the import at the top of the file: #import {{< /highlight >}} -Replace the existing bundle URL: +In the `bundleURL` method, replace the `NSBundle` URL with the CodePush equivalent: {{< highlight objc "style=paraiso-dark">}} -- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +- (NSURL *)bundleURL { - #if DEBUG - return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; - #else - return [CodePush bundleURL]; // <-- replaces the original bundleURL call - #endif +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"]; +#else + return [CodePush bundleURL]; // <-- Replaces [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif } {{< /highlight >}} @@ -264,12 +279,11 @@ Update the `bundleURL` method: {{< highlight swift "style=paraiso-dark">}} override func bundleURL() -> URL? { #if DEBUG - RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") #else - CodePush.bundleURL() // <-- replaces Bundle.main.url(...) + return CodePush.bundleURL() #endif } - {{< /highlight >}} ### Android — `MainApplication.kt` (React Native < 0.82) @@ -306,6 +320,519 @@ class MainApplication : Application(), ReactApplication { --- +## CNG projects: apps without native folders + +In a **Continuous Native Generation (CNG)** project the `ios/` and `android/` +directories do not live in the repository. They are generated on demand by +`expo prebuild`, which runs automatically inside EAS Build and can be triggered +locally at any time with: + +{{< highlight bash "style=paraiso-dark">}} +npx expo prebuild --clean +{{< /highlight >}} + +Any edits made directly to those folders are silently overwritten the next time +`prebuild` runs. This is the same problem `expo-updates` itself solves — it ships +with an Expo **config plugin** that injects its native configuration into the +freshly generated files during `prebuild`. The same technique works for CodePush. + +There are two ways to handle this. Choose the one that fits your team's workflow. + +| | Option A — Config plugin | Option B — Post-prebuild script | +|---|---|---| +| Runs automatically during `prebuild` | ✅ | ❌ (requires an extra CI step) | +| Works identically locally and in CI | ✅ | ❌ | +| Survives `prebuild --clean` | ✅ | ✅ | +| Complexity | Moderate | Simple | +| Risk from RN version changes | Low | Higher (string matching) | + +**Option A is recommended** for teams that fully embrace CNG. Option B is a +pragmatic fallback if a config plugin feels like too much overhead for your +project right now. + +--- + +### Option A — Expo config plugin (recommended) + +#### 1. Install the config-plugins package + +If it is not already in your project: + +{{< highlight bash "style=paraiso-dark">}} +# npm +npm install --save-dev @expo/config-plugins + +# yarn +yarn add --dev @expo/config-plugins +{{< /highlight >}} + +#### 2. Create `app.plugin.js` at the project root + +This plugin modifies the four locations that Steps 6 and 7 cover for bare +projects — `Info.plist`, `strings.xml`, `build.gradle`, `AppDelegate`, and +`MainApplication` — and handles both React Native < 0.82 and ≥ 0.82 for the +`MainApplication` change. + +{{< highlight js "style=paraiso-dark">}} +// app.plugin.js +const { + withInfoPlist, + withStringsXml, + withAppBuildGradle, + withAppDelegate, + withMainApplication, +} = require('@expo/config-plugins'); + +const CODEPUSH_SERVER_URL = 'https://codepush.pro/'; + +// ─── iOS: Info.plist ───────────────────────────────────────────────────────── + +function withCodePushIOS(config, { iosDeploymentKey = '' }) { + return withInfoPlist(config, (c) => { + c.modResults['CodePushServerURL'] = CODEPUSH_SERVER_URL; + c.modResults['CodePushDeploymentKey'] = iosDeploymentKey; + return c; + }); +} + +// ─── Android: strings.xml ──────────────────────────────────────────────────── + +function withCodePushAndroidStrings(config, { androidDeploymentKey = '' }) { + return withStringsXml(config, (c) => { + const strings = c.modResults.resources.string ?? []; + + const set = (name, value) => { + const existing = strings.find((s) => s.$.name === name); + if (existing) { + existing._ = value; + } else { + strings.push({ $: { name, moduleConfig: 'true' }, _: value }); + } + }; + + set('CodePushServerUrl', CODEPUSH_SERVER_URL); + set('CodePushDeploymentKey', androidDeploymentKey); + c.modResults.resources.string = strings; + return c; + }); +} + +// ─── Android: build.gradle ─────────────────────────────────────────────────── + +function withCodePushAndroidGradle(config) { + return withAppBuildGradle(config, (c) => { + const line = + 'apply from: "../../node_modules/' + + '@code-push-next/react-native-code-push/android/codepush.gradle"'; + if (!c.modResults.contents.includes('codepush.gradle')) { + c.modResults.contents += `\n${line}\n`; + } + return c; + }); +} + +// ─── iOS: AppDelegate ──────────────────────────────────────────────────────── +// +// NOTE: The exact structure of AppDelegate varies across React Native versions. +// After running `expo prebuild --clean` for the first time, inspect the +// generated AppDelegate.swift (or .mm) to confirm the replacement landed +// correctly before committing the plugin to your team. +// +// The patterns below cover: +// • New Architecture / RN ≥ 0.71 Swift projects (bundleURL override) +// • Legacy Objective-C projects (sourceURLForBridge) + +function withCodePushAppDelegate(config) { + return withAppDelegate(config, (c) => { + let src = c.modResults.contents; + + if (c.modResults.language === 'swift') { + // Add import after the last import line if not already present + if (!src.includes('import CodePush')) { + src = src.replace( + /^(import \S+)$/m, + (match) => `${match}\nimport CodePush` + ); + } + // Replace the embedded bundle URL inside bundleURL() + src = src.replace( + /Bundle\.main\.url\(\s*forResource:\s*"main",\s*withExtension:\s*"jsbundle"\s*\)/g, + 'CodePush.bundleURL()' + ); + } else { + // Objective-C / Objective-C++ + if (!src.includes('#import ')) { + src = src.replace( + /#import "AppDelegate\.h"/, + '#import "AppDelegate.h"\n#import ' + ); + } + src = src.replace( + /\[\[NSBundle mainBundle\] URLForResource:@"main" withExtension:@"jsbundle"\]/g, + '[CodePush bundleURL]' + ); + } + + c.modResults.contents = src; + return c; + }); +} + +// ─── Android: MainApplication.kt ───────────────────────────────────────────── +// +// Supports both MainApplication architectures: +// • RN < 0.82 — overrides getJSBundleFile() inside DefaultReactNativeHost +// • RN ≥ 0.82 — passes jsBundleFilePath to getDefaultReactHost() +// +// The correct branch is chosen by detecting which pattern is present in the +// generated file, so the plugin works across RN versions without configuration. + +function withCodePushMainApplication(config) { + return withMainApplication(config, (c) => { + let src = c.modResults.contents; + + // Add import if missing + if (!src.includes('com.microsoft.codepush.react.CodePush')) { + src = src.replace( + /(import com\.facebook\.react\.ReactApplication)/, + '$1\nimport com.microsoft.codepush.react.CodePush' + ); + } + + if (src.includes('getDefaultReactHost')) { + // ── RN ≥ 0.82: inject jsBundleFilePath into getDefaultReactHost() ── + // We target the opening of the function call broadly to avoid depending + // on named-argument ordering, which may change across RN template versions. + // After running `expo prebuild --clean`, verify that MainApplication.kt + // contains `jsBundleFilePath = CodePush.getJSBundleFile()`. + if (!src.includes('CodePush.getJSBundleFile()')) { + const patched = src.replace( + /(getDefaultReactHost\s*\([^)]+)\)/, + '$1,\n jsBundleFilePath = CodePush.getJSBundleFile()\n )' + ); + if (patched === src) { + // Regex did not match — template structure may differ in this RN version. + // Fall through and let the verification step catch it. + console.warn( + '⚠️ withCodePushMainApplication: could not inject jsBundleFilePath. ' + + 'Verify MainApplication.kt after prebuild.' + ); + } + src = patched; + } + } else { + // ── RN < 0.82: override getJSBundleFile() inside DefaultReactNativeHost ── + if (!src.includes('CodePush.getJSBundleFile()')) { + src = src.replace( + /object\s*:\s*DefaultReactNativeHost\(this\)\s*\{/, + 'object : DefaultReactNativeHost(this) {\n' + + ' override fun getJSBundleFile(): String = CodePush.getJSBundleFile()\n' + ); + } + } + + c.modResults.contents = src; + return c; + }); +} + +// ─── Root plugin ───────────────────────────────────────────────────────────── + +module.exports = function withCodePush(config, props = {}) { + config = withCodePushIOS(config, props); + config = withCodePushAndroidStrings(config, props); + config = withCodePushAndroidGradle(config); + config = withCodePushAppDelegate(config); + config = withCodePushMainApplication(config); + return config; +}; +{{< /highlight >}} + +#### 3. Register the plugin in `app.config.js` + +Use `app.config.js` (not `app.json`) so deployment keys can be read from +environment variables at build time — avoiding hardcoded keys for different +environments (Staging vs Production). + +{{< highlight js "style=paraiso-dark">}} +// app.config.js +export default ({ config }) => ({ + ...config, + plugins: [ + ...(config.plugins ?? []), + [ + './app.plugin', + { + iosDeploymentKey: process.env.CODEPUSH_IOS_KEY ?? '', + androidDeploymentKey: process.env.CODEPUSH_ANDROID_KEY ?? '', + }, + ], + ], +}); +{{< /highlight >}} + +In Codemagic, define `CODEPUSH_IOS_KEY` and `CODEPUSH_ANDROID_KEY` as **secure +environment variables** in your project settings — one set for Staging builds and +one set for Production builds. The plugin picks up whichever pair is active for +the current workflow run. + +#### 4. Verify the output + +Run `prebuild` locally and inspect the generated files to confirm the plugin +applied all changes correctly. Do this the first time you add the plugin, and +again after any React Native version upgrade. + +{{< highlight bash "style=paraiso-dark">}} +npx expo prebuild --clean + +# iOS +grep -A1 "CodePushServerURL" ios//Info.plist +grep "CodePush" ios//AppDelegate.swift + +# Android +grep "CodePush" android/app/src/main/res/values/strings.xml +grep "codepush.gradle" android/app/build.gradle +grep "CodePush" android/app/src/main/java/**/MainApplication.kt +{{< /highlight >}} + +If any replacement did not land — most commonly because your RN version's +AppDelegate or MainApplication uses a slightly different structure — adjust the +regex in `app.plugin.js` to match, re-run `prebuild --clean`, and verify again. + +Once the output looks correct the generated `ios/` and `android/` directories can +be deleted; they will be regenerated automatically by EAS Build and by +Codemagic if you run `expo prebuild` as a workflow step. + +--- + +### Option B — Post-prebuild script in Codemagic + +If you prefer not to write a config plugin, add two steps to your Codemagic +workflow: run `expo prebuild` first, then immediately patch the generated files +with a Node.js script before the native build starts. + +#### 1. Create `scripts/inject-codepush.js` + +{{< highlight js "style=paraiso-dark">}} +#!/usr/bin/env node +/** + * Injects CodePush configuration into freshly prebuild-generated native files. + * Run immediately after `expo prebuild` in CI. + * + * Required environment variables: + * APP_NAME — must match the Xcode target folder name (e.g. "MyApp") + * CODEPUSH_IOS_KEY — deployment key for the iOS build + * CODEPUSH_ANDROID_KEY — deployment key for the Android build + */ + +const fs = require('fs'); +const path = require('path'); + +const SERVER_URL = 'https://codepush.pro/'; +const APP_NAME = process.env.APP_NAME; +const IOS_KEY = process.env.CODEPUSH_IOS_KEY ?? ''; +const ANDROID_KEY = process.env.CODEPUSH_ANDROID_KEY ?? ''; + +if (!APP_NAME) { + console.error('❌ APP_NAME environment variable is required.'); + process.exit(1); +} + +// ─── Helper ────────────────────────────────────────────────────────────────── + +function patch(filePath, transform) { + const original = fs.readFileSync(filePath, 'utf8'); + const result = transform(original); + if (result !== original) { + fs.writeFileSync(filePath, result); + console.log(`✅ Patched: ${filePath}`); + } else { + console.log(`⏭ Already patched or pattern not found: ${filePath}`); + } +} + +// ─── iOS: Info.plist ───────────────────────────────────────────────────────── + +patch( + path.join(__dirname, '..', 'ios', APP_NAME, 'Info.plist'), + (src) => { + if (src.includes('CodePushServerURL')) return src; + return src.replace( + '\n', + `\tCodePushServerURL\n\t${SERVER_URL}\n` + + `\tCodePushDeploymentKey\n\t${IOS_KEY}\n` + + `\n` + ); + } +); + +// ─── iOS: AppDelegate (.swift, .mm, or .m) ─────────────────────────────────── +// expo prebuild generates AppDelegate.mm (Objective-C++) by default for most +// React Native versions. Swift is used in some RN 0.71+ templates. We detect +// whichever file exists rather than assuming an extension. + +const iosDelegateDir = path.join(__dirname, '..', 'ios', APP_NAME); +const delegateCandidates = ['AppDelegate.swift', 'AppDelegate.mm', 'AppDelegate.m']; +const delegateEntry = delegateCandidates + .map((f) => ({ file: f, full: path.join(iosDelegateDir, f) })) + .find(({ full }) => fs.existsSync(full)); + +if (!delegateEntry) { + console.error(`❌ Could not locate AppDelegate in ios/${APP_NAME}/ (tried .swift, .mm, .m)`); + process.exit(1); +} + +patch(delegateEntry.full, (src) => { + let out = src; + + if (delegateEntry.file.endsWith('.swift')) { + // Swift + if (!out.includes('import CodePush')) { + out = out.replace(/^(import \S+)$/m, '$1\nimport CodePush'); + } + out = out.replace( + /Bundle\.main\.url\(\s*forResource:\s*"main",\s*withExtension:\s*"jsbundle"\s*\)/g, + 'CodePush.bundleURL()' + ); + } else { + // Objective-C / Objective-C++ (.mm or .m) + if (!out.includes('#import ')) { + out = out.replace( + /#import "AppDelegate\.h"/, + '#import "AppDelegate.h"\n#import ' + ); + } + out = out.replace( + /\[\[NSBundle mainBundle\] URLForResource:@"main" withExtension:@"jsbundle"\]/g, + '[CodePush bundleURL]' + ); + } + + return out; +}); + +// ─── Android: strings.xml ──────────────────────────────────────────────────── + +patch( + path.join( + __dirname, '..', 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml' + ), + (src) => { + if (src.includes('CodePushServerUrl')) return src; + return src.replace( + '', + ` ${SERVER_URL}\n` + + ` ${ANDROID_KEY}\n` + + `` + ); + } +); + +// ─── Android: build.gradle ─────────────────────────────────────────────────── + +patch( + path.join(__dirname, '..', 'android', 'app', 'build.gradle'), + (src) => { + const line = + 'apply from: "../../node_modules/' + + '@code-push-next/react-native-code-push/android/codepush.gradle"'; + return src.includes('codepush.gradle') ? src : `${src}\n${line}\n`; + } +); + +// ─── Android: MainApplication.kt ───────────────────────────────────────────── +// Supports both RN < 0.82 (getJSBundleFile) and RN ≥ 0.82 (getDefaultReactHost) + +const mainAppGlob = path.join( + __dirname, '..', 'android', 'app', 'src', 'main', 'java' +); + +function findFile(dir, name) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const found = findFile(full, name); + if (found) return found; + } else if (entry.name === name) { + return full; + } + } + return null; +} + +const mainAppPath = findFile(mainAppGlob, 'MainApplication.kt'); +if (!mainAppPath) { + console.error('❌ Could not locate MainApplication.kt'); + process.exit(1); +} + +patch(mainAppPath, (src) => { + let out = src; + + if (!out.includes('com.microsoft.codepush.react.CodePush')) { + out = out.replace( + /(import com\.facebook\.react\.ReactApplication)/, + '$1\nimport com.microsoft.codepush.react.CodePush' + ); + } + + if (!out.includes('CodePush.getJSBundleFile()')) { + if (out.includes('getDefaultReactHost')) { + // RN ≥ 0.82 — target the function call broadly; do not rely on + // named-argument ordering, which may vary across RN template versions. + const patched = out.replace( + /(getDefaultReactHost\s*\([^)]+)\)/, + '$1,\n jsBundleFilePath = CodePush.getJSBundleFile()\n )' + ); + if (patched === out) { + console.warn( + '⚠️ Could not inject jsBundleFilePath into getDefaultReactHost(). ' + + 'Verify MainApplication.kt manually — the template structure may differ ' + + 'in your React Native version.' + ); + } + out = patched; + } else { + // RN < 0.82 + out = out.replace( + /object\s*:\s*DefaultReactNativeHost\(this\)\s*\{/, + 'object : DefaultReactNativeHost(this) {\n' + + ' override fun getJSBundleFile(): String = CodePush.getJSBundleFile()\n' + ); + } + } + + return out; +}); + +console.log('\nCodePush injection complete.'); +{{< /highlight >}} + +#### 2. Add prebuild and injection steps to `codemagic.yaml` + +{{< highlight yaml "style=paraiso-dark">}} +workflows: + your-workflow: + environment: + vars: + APP_NAME: MyApp + CODEPUSH_IOS_KEY: $CODEPUSH_IOS_STAGING_KEY + CODEPUSH_ANDROID_KEY: $CODEPUSH_ANDROID_STAGING_KEY + scripts: + - name: Run Expo prebuild + script: npx expo prebuild --clean + + - name: Inject CodePush native configuration + script: node scripts/inject-codepush.js + + # … your existing iOS / Android build steps follow +{{< /highlight >}} + +Store `CODEPUSH_IOS_STAGING_KEY`, `CODEPUSH_ANDROID_STAGING_KEY` (and any +Production equivalents) as **secure environment variables** in Codemagic project +settings. Use different variable values per workflow to ensure the correct +deployment key is injected for each environment. + ## Step 8 — Wrap the root component In your app's entry point (typically `App.tsx` or `index.js`), wrap the root component with the CodePush HOC: @@ -322,7 +849,7 @@ export default codePush(App); This enables the SDK to check for updates automatically on app launch. For advanced update strategies (background downloads, custom dialogs, mandatory update UI), see the [Advanced sync options](https://docs.codemagic.io/rn-codepush/advanced-sync-options/) documentation. -{{}} +{{}} Using Expo Router? If your project uses Expo Router, the root of your application is handled differently. Instead of wrapping App.tsx, apply the CodePush HOC to the default export in `app/_layout.tsx`: @@ -338,7 +865,6 @@ export default codePush(RootLayout); {{}} - --- ## Step 9 — Validate end-to-end @@ -382,16 +908,13 @@ Store `CODEPUSH_TOKEN` as a **secure environment variable** in your Codemagic pr ### GitHub Actions (if applicable) {{< highlight yaml "style=paraiso-dark">}} -- name: Install CodePush CLI - run: npm install -g @codemagic/code-push-cli - - name: Release CodePush update to Staging env: CODEPUSH_TOKEN: ${{ secrets.CODEPUSH_TOKEN }} run: | - code-push login "https://codepush.pro" --accessKey $CODEPUSH_TOKEN - code-push release-react MyApp-iOS ios --deployment-name Staging - code-push release-react MyApp-Android android --deployment-name Staging + npx @codemagic/code-push-cli login "https://codepush.pro" --accessKey $CODEPUSH_TOKEN + npx @codemagic/code-push-cli release-react MyApp-iOS ios --deployment-name Staging + npx @codemagic/code-push-cli release-react MyApp-Android android --deployment-name Staging {{< /highlight >}} --- @@ -400,13 +923,8 @@ Store `CODEPUSH_TOKEN` as a **secure environment variable** in your Codemagic pr The recommended CodePush release workflow mirrors the EAS Update concept of channels but adds an explicit promotion step: - {{< mermaid >}} graph LR - -%% Colors %% -classDef red fill:#ed2633,stroke:#FFF,stroke-width:1px,color:#fff - RELEASE(Release to Staging) --> TEST(Internal testing and QA) TEST --> PROMOTE(Promote to Production - no rebuild required) {{< /mermaid >}}