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 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.)
- ☁️ If you don’t have the time to put the aforementioned strategy into practice above, you can pay Nitro Build Service (x5 Faster). I gave it a try and was quite delighted with their work:
2) Tricks & Cache
- 💾 Cache Pods may speed up iOS (~x4 Faster) generally very quick, but still has to use MacOS runners
- Docs: Use a compiler cache (Github Action implementation)
- Turning off Flipper on CI save 3min
- Disable minification & 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 🙏, 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)
Configuration project
The first is to create a new iOS and Android app in the appcenter: https://appcenter.ms/orgs/MyOrganizationTest/applications/create
After that, you need to create standard deployments:
By default, two staging and production deployments will be created:
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
// 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
. Then continue with the native parts:
Now let’s write some code. I created a simple hook useSyncOnAppStart
that will encapsulate all logic that you need and should be utilized in the app’s root component:
// App.tsx
import {useSyncOnAppStart} from './codepush/useSyncOnAppStart'
export detault function App(){
useSyncOnAppStart(); // Init CodePush sync on App mount
return <View/>;
}
// 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
, 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(...)
). 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 module.
# Install
yarn add react-native-code-push-dev-menu
# or npm install react-native-code-push-dev-menu
Usage example:
// 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
Next, let’s build the config file scripts/envs.sh
#!/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
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.
#!/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:
# 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 a create a .github/workflows/non_prod_codepush.yml
file:
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:
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; 🤩
- 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 ©
Top comments (0)