Don't pay for painfully slow React Native iOS builds anymore.
Tags: reactnative, cicd, codepush, ios
Published: 2023-01-15 20:05:10

This article discusses the CI/CD ecosystem status for React Native projects (with a focus on iOS). Thank Apple that Xcode, which requires MacOS, is essential to creating iOS apps.


## The approaches

Everyone is aware that Xcode is very slow when bundling Objective C code. At the moment we have a lot of strategies to speed up iOS builds. I spread them to two group:

**1) Build only Javascript**
- 💉 [Building and injecting a JS bundle](https://www.uglydirtylittlestrawberry.co.uk/posts/react-native-ios-build-and-inject-bundle/) a basic concept: If the native code wasn’t changed, simply create JS bundle and inject it into the precompiled application. (You must take an extra step to convert a js file to bytecode if you use the [Hermes js engine](https://github.com/facebook/hermes).)
- ☁️ If you don’t have the time to put the aforementioned strategy into practice above, you can pay [Nitro Build Service](https://nitro.build/) (x5 Faster). I gave it a try and was quite delighted with their work:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1rk8teotuedsmfgyk9fu.png)


**2) Tricks & Cache**

- 💾 [Cache Pods may speed up iOS](https://dev.to/retyui/react-native-how-speed-up-ios-build-4x-using-cache-pods-597c) (~x4 Faster) generally very quick, but still has to use MacOS runners
- Docs: [Use a compiler cache](https://reactnative.dev/docs/build-speed#use-a-compiler-cache) (Github Action [implementation](https://github.com/mikehardy/buildcache-action))
- [Turning off Flipper on CI](https://dev.to/retyui/disable-flipper-on-ci-builds-for-react-native-complete-guide-bnd) save 3min
- [Disable minification](https://dev.to/retyui/react-native-cicd-build-speed-improved-by-22-with-one-line-of-code-5g14) & metro cache for Staging/Beta builds
- Switch to M1 MacOS processors from Intel on CI

However, all of the above methods must use the Apple build tool or third-party services 💵. But I do have something extra special for you. 😏🎁

---

## YES it’s CodePush!

I myself prefer to concentrate on a free and open-source alternative called [CodePush](https://learn.microsoft.com/ru-ru/appcenter/distribution/codepush/) 🙏, which enables us to truly “stop paying” for MacOS runners. Installing new native dependencies or touching native code infrequently is ideal for you. How is this possible?

I use the next strategy:

- If native code (usually `./android/**`, `./ios/**`) was changed, we would apply the classic flow: Build a whole app for Android and iOS that you should already have.
- However, if only the Javascript code was modified, what would be the point of creating new native code? Instead, create a Javascript bundle and deploy it to CodePush. Of course, you don’t need to use MacOS runners to compile JS. Cheapest Linux should suffice 💰.

After a successful build, your team members may quickly apply a deployed CodePush release by using the app’s dev menu.

> Demo repo: https://github.com/retyui/CodePushCiCd (ci/cd Github Actions, dev-menu, client part)

{% embed https://vimeo.com/787196148 %}

---

## Configuration project

The first is to create a new iOS and Android app in the appcenter: https://appcenter.ms/orgs/MyOrganizationTest/applications/create


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ssrhnmw0yi2qppvpm86i.png)


After that, you need to create standard deployments:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8zuq0pw072u1ellnsr6h.png)

By default, two staging and production deployments will be created:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/31fhjhyd6rtcgeu7l0a1.png)

The final step in the admin panel is to collect the **production deployment keys** of iOS and Android apps. (will be used on the JS side later). Open the CodePush page and tap on a spanner icon next to the Deployment select


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h6iyqogwc2u1qenkvn3r.png)

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6wzd6fhgvyz2tu5tc50y.png)

```tsx
// constants.ts
import {Platform} from 'react-native';

const CODE_PUSH_IOS_PROD_KEY = 'Gbsg8cTjdcSWOwgJEOEHqk8VE1x6ITThqvNe0';
const CODE_PUSH_ANDROID_PROD_KEY = 'Ob7LrQg_w-l4w1SOLDYT5XBw76_6Pz-NVCed1';

export const CODE_PUSH_PROD_KEY = Platform.select({
  ios: CODE_PUSH_IOS_PROD_KEY,
  default: CODE_PUSH_ANDROID_PROD_KEY,
});
```


## Client side (core logic)

Begin by adding a [`react-native-code-push module`](https://github.com/microsoft/react-native-code-push#getting-started). Then continue with the native parts:

- [iOS Setup](https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-ios.md)
- [Android Setup](https://github.com/microsoft/react-native-code-push/blob/master/docs/setup-android.md)

Now let’s write some code. I created a simple hook [`useSyncOnAppStart`](https://github.com/retyui/CodePushCiCd/blob/main/codepush/useSyncOnAppStart.ts) that will encapsulate all logic that you need and should be utilized in the app’s root component:

```tsx
// App.tsx
import {useSyncOnAppStart} from './codepush/useSyncOnAppStart'

export detault function App(){
  useSyncOnAppStart(); // Init CodePush sync on App mount

  return <View/>;
}
```


```tsx
// codepush/useSyncOnAppStart.ts
import {useEffect} from 'react';
import {AppState, AppStateStatus} from 'react-native';
import CodePush from 'react-native-code-push';

import {CODE_PUSH_PROD_KEY} from '../constants';

const noop = () => {};

const isDeploymentNotFoundError = (error: Error) =>
  error?.message?.includes?.('No deployment found');

function syncOnAppStart() {
  async function start() {
    try {
      // see comment#1
      const runningPackage = await CodePush.getUpdateMetadata(
        CodePush.UpdateState.RUNNING,
      );

      const isNonProduction =
        runningPackage?.deploymentKey &&
        runningPackage.deploymentKey !== CODE_PUSH_PROD_KEY;

      // see comment#2
      const nonProdConfig = {
        // Non-Prod sync (To enable this variant use "Dev Menu" to change deployment)
        deploymentKey: runningPackage?.deploymentKey,
        installMode: CodePush.InstallMode.IMMEDIATE,
        updateDialog: {
          // Ask mobile team member before install new version for custom deployment
        },
      };

      // see comment#3
      const prodConfig = {
        // Prod sync (↓↓↓ Default values, see: https://github.com/microsoft/react-native-code-push/blob/master/docs/api-js.md#codepushoptions)
        installMode: CodePush.InstallMode.ON_NEXT_RESTART,
        deploymentKey: CODE_PUSH_PROD_KEY,
        rollbackRetryOptions: {
          maxRetryAttempts: 1,
          delayInHours: 24,
        },
      };

      // see comment#4
      const status = await CodePush.sync(
        isNonProduction ? nonProdConfig : prodConfig,
      );

      return status;
    } catch (error) {
      // see comment#5
      if (isDeploymentNotFoundError(error)) {
        return CodePush.clearUpdates();
      }
      // see comment#6
      trackError(error);
    }
  }

  // see comment#7
  const onAppStateChange = async (newState: AppStateStatus) => {
    if (newState === 'active') {
      await start();
    }
  };

  let unsubscribe = noop;

  start()
    .catch(noop)
    .finally(() => {
      const subscription = AppState.addEventListener(
        'change',
        onAppStateChange,
      );

      unsubscribe = () => subscription.remove();
    });

  return () => {
    unsubscribe();
  };
}

export const useSyncOnAppStart = (): void => {
  useEffect(() => {
    const unsubscribe = syncOnAppStart();

    return unsubscribe;
  }, []);
};
```

**Comment#1**: Get information about the installed package first. **Comment#2**: When dealing with non-production packages, we employ [`codepush.InstallMode.IMMEDIATE`](https://github.com/microsoft/react-native-code-push/blob/master/docs/api-js.md#installmode), which displays an Update Alert to the user. `Comment#3`: For production builds app will use default options. `Comment#4`: Run code push sync (read docs: [`CodePush.sync(...)`](https://github.com/microsoft/react-native-code-push/blob/master/docs/api-js.md#codepushsync)). **Comment#5**: The “No deployment found” problem occasionally occurs when a developer still has an install package from a deleted development deployment. **Comment#6**: Send an error to you for error tracking (like Sentry). **Comment#7**: If a new codepush release was made while the app was being used, add an app state listener to keep everything in sync.

## Client side (dev-menu)

This section will be lot easier. To make applying custom builds simple, I built the [react-native-code-push-dev-menu](https://github.com/retyui/react-native-code-push-dev-menu) module.

```bash
# Install
yarn add react-native-code-push-dev-menu
# or npm install react-native-code-push-dev-menu
```

**Usage example:**

```tsx
// DevMenuScreen.tsx
import {
  CodePushDeMenuButton,
  configurateProject,
} from 'react-native-code-push-dev-menu';

configurateProject({
  readonlyAccessToken: Platform.select({
    // Read-only access tokens 
    // https://docs.microsoft.com/en-us/appcenter/api-docs/#creating-an-app-center-app-api-token
    ios: '128009dc42ded5e71ef21e007a24eb67b5c3279f',
    default: '42f471742864bd9c1917f322918b163a90d13904',
  }),
  appCenterAppName: Platform.select({
    ios: 'MyApp-iOS',
    default: 'MyApp-Android',
  }),
  appCenterOrgName: 'MyOrganizationTest',
});

function DevMenuScreen() {
  return (
    <SafeAreaView>
      <CodePushDeMenuButton />
      // Other dev things
    </SafeAreaView>
  );
}
```



## CI/CD

Automated CodePush builds were set up last. In admin of appcener you need to create `APPCENTER_ACCESS_TOKEN` https://appcenter.ms/settings/apitokens with fill access


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jjfwjq8skm2cewz0rga7.png)


Next, let’s build the config file [`scripts/envs.sh`](https://github.com/retyui/CodePushCiCd/blob/main/scripts/envs.sh)

```bash
#!/bin/bash
export APP_CENTER_ORG_NAME=MyOrganizationTest

export APP_CENTER_APP_NAME_IOS=MyApp-iOS
export APP_CENTER_APP_NAME_ANDROID=MyApp-Android
```

The following step is to write a build script called [`scripts/codepush-non-prod.sh`](https://github.com/retyui/CodePushCiCd/blob/main/scripts/codepush-non-prod.sh) that will create a new deployment (using the branch name as the deployment name) and bundle a JS bundle that will be deployed in that environment.


```bash
#!/bin/bash

set -x # all executed commands are printed to the terminal
set -e # immediately exit if any command has a non-zero exit status

source "./scripts/envs.sh"


if [ -z "$DEPLOYMENT_NAME" ]; then
    echo "Please sure that DEPLOYMENT_NAME exists"
    exit 1
fi

if [ "$DEPLOYMENT_NAME" == "Production" ]; then
    echo "You can't use reserved name 'Production' for deployment"
    exit 1
fi

if [ -z "$APPCENTER_ACCESS_TOKEN" ]; then
    echo "Please sure that APPCENTER_ACCESS_TOKEN exists"
    exit 1
fi

if [ "$PLATFORM" != "ios" ] && [ "$PLATFORM" != "android"  ]
then
    echo "Please sure that you set PLATFORM env variable (ios | android)"
    exit 1
fi

APP_CENTER_APP_NAME="$APP_CENTER_APP_NAME_ANDROID"

if [ "$PLATFORM" == "ios" ]; then
    APP_CENTER_APP_NAME="$APP_CENTER_APP_NAME_IOS"
fi

# Create new Codepush Deployment
appcenter codepush deployment add -a "$APP_CENTER_ORG_NAME/$APP_CENTER_APP_NAME" "$DEPLOYMENT_NAME" || true # Ignore "deployment named test-build already exists" error
# Create JS bundle
appcenter codepush release-react -a "$APP_CENTER_ORG_NAME/$APP_CENTER_APP_NAME" -d "$DEPLOYMENT_NAME" --target-binary-version "*" --description "$DESCRIPTION"
```

You may now do a local test:

```bash
# Required
export APPCENTER_ACCESS_TOKEN=xxxx
export DEPLOYMENT_NAME=my-branch-name
# Optional
export DESCRIPTION="My desc..."

PLATFORM=ios ./scripts/codepush-non-prod.sh     # Codepush release for iOS
PLATFORM=android ./scripts/codepush-non-prod.sh # Codepush release for Android
```

And if you use a [Github Actions](https://github.com/features/actions) a create a [`.github/workflows/non_prod_codepush.yml`](https://github.com/retyui/CodePushCiCd/blob/main/.github/workflows/non_prod_codepush.yml) file:

```yml
name: Mobile CodePush

on:
  pull_request:
    paths-ignore:
      - 'android/**'
      - 'ios/**'
  workflow_dispatch:


concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  android_beta_mobile_build:
    name: Build Non-Prod
    runs-on: ubuntu-20.04
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 1

      - name: Yarn cache
        uses: actions/cache@v3
        id: node_cache
        with:
          path: node_modules
          key: ${{ runner.os }}-yarn-${{ hashFiles('./yarn.lock') }}

      - name: Install node_modules
        run: yarn install --frozen-lockfile
        if: steps.node_cache.outputs.cache-hit != 'true'


      - name: Create Beta CodePush Release
        env:
          APPCENTER_ACCESS_TOKEN: ${{ secrets.APPCENTER_ACCESS_TOKEN }}
          DEPLOYMENT_NAME: ${{ github.head_ref || github.ref_name }} # `head_ref` pull_request event, `ref_name` workflow_dispatch event
          DESCRIPTION: Made by - ${{ github.actor }}
          NODE_ENV: production
        run: |
          npm install -g appcenter-cli@2.12.0
          PLATFORM=android ./scripts/codepush-non-prod.sh
          PLATFORM=ios ./scripts/codepush-non-prod.sh
```

Additionally, a manual run may be used to test it on CI:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k251gfqq39emt2omfyop.png)


---

## Let’s review what was created 🥳🤩🎂🎉

- All PRs that just modify js will be released to CodePush (branch name will be used as Deployment name);
- You may quickly apply those changes by using a [Dev Menu](https://dev.to/retyui/the-dev-menu-is-a-need-for-all-react-native-apps-1i8); 🤩
- You do not need to use MacOS to create a codepush release;🎉

---

If you have any questions, please feel free to ask them in the comments section.

MurAmur ©