Getting started with continuous integration in React Native - Part 3: Custom CI setup with Bitrise
Knowledge of React and React Native is required. Your machine should be set up for React Native development.
This is the third and final part of the series on getting started with continuous integration in React Native. In this part, we’re going to use Bitrise for a more customizable CI setup. Specifically, you’re going to learn the following:
- How to set up a React Native project in Bitrise.
- How to run Jest and Detox tests.
- How to configure the build workflow.
Prerequisites
To follow this tutorial, you need to have basic knowledge of React and React Native. The project that we will be working on uses Redux, Redux Saga, and Detox, so experience in using those will be helpful as well.
These are the package versions that we will be using:
- Node 8.3.0
- Yarn 1.7.0
- React Native 0.50
- Detox 8.1.6
- Mocha 4.0.1
For other dependencies, check out the package.json
file of the project.
Reading the first and second part of this series is optional if you already have previous knowledge of how continuous integration is done in React Native.
If you want to have a brief overview of the app that we’re working on, be sure to check out part one of this series.
Initial project setup
To make sure the new project will be as clean as possible, we will be initializing a new React Native project and push it on a repo separate to the one we used on part two. Go ahead and create a new repo named ReactNativeCI_Bitrise on GitHub.
Next, clone the project repo (the GitHub repo for this series, not the one you just created) and switch to the part2
branch:
git clone https://github.com/anchetaWern/ReactNativeCI.git
cd ReactNativeCI
git checkout part2
We’re switching to the part2
branch so we can get the final output from the second part of this series.
Next, initialize a new React Native project which uses the same version as the project repo. We’re naming it ReactNativeCI instead of ReactNativeCI_Bitrise so we won’t have any naming issues. You can also rename your GitHub repo to ReactNativeCI if you don’t have any further use for the source code we used on part two of this series:
react-native init ReactNativeCI --version react-native@0.50
cd ReactNativeCI
After that, copy the src
folder, App.js
, and package.json
file from the repo you cloned earlier to the project you just created.
Update the package.json
file so it looks like this. Note that this removes all the App Center packages from part two:
{
"name": "ReactNativeCI",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
"test": "jest"
},
"dependencies": {
"react": "16.0.0",
"react-native": "0.50",
"react-native-vector-icons": "^5.0.0",
"react-redux": "^5.0.7",
"redux": "^4.0.0",
"prop-types": "^15.6.2"
},
"devDependencies": {
"babel-jest": "23.4.2",
"babel-preset-react-native": "4.0.0",
"jest": "23.5.0",
"mocha": "4.0.1",
"react-test-renderer": "16.0.0"
},
"jest": {
"preset": "react-native"
}
}
Next, install all the packages, link the native modules, and run the app:
yarn install
react-native link
react-native run-android
react-native run-ios
Only proceed to the next section if you managed to run the app locally. Because if it wouldn’t work locally then it’s not going to work on the CI server either.
Once you got the app running, commit your changes and push it to your repo:
git add .
git commit -m "initialize project"
git remote add origin git@github.com:YOUR_GITHUB_USERNAME/YOUR_REPOS_NAME.git
git push origin master
Adding an app to Bitrise
Create a Bitrise account if you haven’t done so already. Once you’re logged in, you’ll be greeted with the following screen:
Click on the Add first app button to add your app. First, select your GitHub account and the ReactNativeCI repository you forked earlier.
After that, you have to specify the repository access. This is the method used by Bitrise to get access to the repo you forked earlier. Since you’ve already connected your GitHub account to Bitrise, Bitrise is able to add the SSH key used for accessing your repo to your GitHub account. So click on the No, auto-add SSH key button. You will then see it added on your GitHub’s account security page.
Next, it will ask you to enter the name of the branch. Put master on the text field.
At this point, Bitrise will start validating the repository. This is where Bitrise determines what kind of project this is so that it can recommend a specific configuration that you can select. It might be a good idea to grab a drink while it’s validating as it will take a minute or two:
If it’s taking too long, you can click on the Expand Logs link to see what Bitrise is doing behind the scenes.
Once it’s done validating the repository, it should have pre-selected the android
and gradlew
path. It will then let you select a few more settings. Make sure you end up with the following once you’re done selecting the config:
From the above configuration, you can see that Bitrise has configurations for both Android and iOS. Note that this doesn’t mean that we will only have to maintain a single Bitrise app instance.
Just like in App Center, we’ll still be creating two app instances, one for each platform. This is to separate the code integration (and eventually the release and deployment) of changes made to the app.
Due to how young React Native is as a platform, there will be times when there are unexpected bugs that only occurs only on Android or iOS. This causes a delay in the time in which new features will be tested, integrated, and delivered to users. This separation makes it easy to only release on Android or iOS, but not both.
The final step is to register a webhook. This allows Bitrise to automatically build the project every time a change is made to the branch you selected earlier. Again, you will see this webhook is registered in your GitHub account’s security page.
Once that’s done, Bitrise will build the app for the first time. We don’t really want to build the app yet because it will fail, so click on the Builds tab and abort the current build. We’ll proceed to manually initiating a build once we’re sure that it will succeed.
Note that when you sign up for a Bitrise account, you’re automatically signed up to the Developer plan. This gives you an unlimited number of builds per month, and each build can take up to 45 minutes. So don’t worry about meeting the maximum builds per month until you come out of their 14-day trial.
Creating the other app instance
Before creating the other app instance for the other platform, first, rename the one you just created to ReactNativeCI-Android. You can do that by going to the Settings tab and updating the Title field. We need to do this so we won’t get confused because Bitrise uses the name of the GitHub repo by default.
Once that’s done, go through the same steps that you just followed to create a new app. Don’t forget to rename the new instance to ReactNativeCI-iOS.
Making changes to the project
Just like in part two, we’ll be making a few changes in this part as well. This time, we will add the functionality for saving the favorited Pokemon to local storage. This way, they will still be marked as a favorite even after the user restarts the app.
The Git workflow we’ll be using is still the same as the one we used on part two. I explained the workflow in part one, so if you haven’t read that, you can do so by going to the CI workflow in React Native section in part one of this series.
Start by creating a develop
branch and creating a new branch off of that:
git checkout -b develop
git checkout -b local-storage
We will be using a couple of new dependencies. One for handling local storage, and another for handling asynchronous operations while working with Redux:
yarn add react-native-simple-store redux-saga
Next, update the src/action/types.js
file to include the new action types for handling asynchronous activity:
export const FAVORITED_CARD = "favorited_card";
// add these
export const LOCAL_DATA_REQUEST = "local_data_request"; // when fetching the data from local storage
export const LOCAL_DATA_SUCCESS = "local_data_success"; // when the data is received
export const LOCAL_DATA_FAILURE = "local_data_failure"; // when there's an error receiving the data
Next, add the code that will dispatch the actions throughout the lifecycle of the local storage data request:
// create new file: src/sagas/index.js
import { takeLatest, call, put } from "redux-saga/effects";
import store from "react-native-simple-store"; // library for working with local storage
// action types
import {
LOCAL_DATA_REQUEST,
LOCAL_DATA_SUCCESS,
LOCAL_DATA_FAILURE
} from "../actions/types";
// watch for actions dispatched to the store
export function* watcherSaga() {
yield takeLatest(LOCAL_DATA_REQUEST, workerSaga);
}
// function for getting the data from local storage
function getLocalData() {
return store.get("app_state"); // fetch the data from local storage that is stored in the "app_state" key
}
function* workerSaga() {
try {
const response = yield call(getLocalData); // trigger the fetching of data from local storage
const cards = response.cards;
yield put({ type: LOCAL_DATA_SUCCESS, cards }); // dispatch the success action (data has been fetched)
} catch (error) {
yield put({ type: LOCAL_DATA_FAILURE, error }); // dispatch the fail action (data was not fetched)
}
}
In the reducer file, make sure that all of the new action types are handled accordingly:
// src/reducers/CardReducer.js
import {
FAVORITED_CARD,
// add these:
LOCAL_DATA_REQUEST,
LOCAL_DATA_SUCCESS,
LOCAL_DATA_FAILURE
} from "../actions/types";
import store from "react-native-simple-store"; // add this
switch (action.type) {
case FAVORITED_CARD:
let cards = state.cards.map(item => {
return item.id == action.payload
? { ...item, is_favorite: !item.is_favorite }
: item;
});
// update the local storage with the copy of the new data
store.update("app_state", {
cards
});
return { ...state, cards };
// add these:
case LOCAL_DATA_REQUEST: // triggered when requesting data from local storage
return { ...state, fetching: true };
case LOCAL_DATA_SUCCESS: // triggered when data is successfully returned from local storage
return { ...state, fetching: false, cards: action.cards };
// only triggered the first time the app is opened because there's no data in the local storage yet
case LOCAL_DATA_FAILURE:
store.update("app_state", INITIAL_STATE); // initialize the local storage
return {
...state,
fetching: false,
cards: INITIAL_STATE.cards // return the initial state instead
};
default:
return state;
}
Next, we need to hook up the watcher saga in the Provider component. This way, it will get triggered when the LOCAL_DATA_REQUEST
action is dispatched:
// src/components/Provider.js
import { createStore, applyMiddleware } from "redux";
import createSagaMiddleware from "redux-saga";
const sagaMiddleware = createSagaMiddleware();
import { watcherSaga } from "../sagas";
const store = createStore(reducers, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(watcherSaga);
Lastly, update the CardList
component to make use of the new fetching
state, as well as trigger the action for fetching the data from local storage:
// src/components/CardList.js
import { View, FlatList, ActivityIndicator } from "react-native";
import { FAVORITED_CARD, LOCAL_DATA_REQUEST } from "../actions/types";
class CardList extends Component {
componentDidMount() {
this.props.requestLocalData();
}
render() {
const { fetching, cards } = this.props;
// add activity indicator (show while fetching data from local storage)
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#333" animating={fetching} />
<FlatList
contentContainerStyle={styles.flatlist}
data={cards}
renderItem={this.renderCard}
numColumns={2}
keyExtractor={(item, index) => item.id.toString()}
/>
</View>
);
}
}
const mapStateToProps = ({ cards, fetching }) => {
return {
...cards,
...fetching
};
};
const mapDispatchToProps = dispatch => {
return {
// dispatch action instead of returning the object containing the action data
favoritedCard: id => {
dispatch({ type: FAVORITED_CARD, payload: id });
},
// add function for dispatching action for initiating local storage data request
requestLocalData: () => {
dispatch({ type: LOCAL_DATA_REQUEST });
}
};
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(CardList);
Once that’s done, update the snapshot (this was added in the starter app so don’t worry about adding it) and commit the changes:
yarn test -u
git add .
git commit -m "add local storage functionality"
At this point, do some manual testing by marking a few Pokemon as a favorite then relaunch the app. If the ones you selected is still selected when the app is relaunched, it means that the new feature is working.
Once you’ve confirmed that the new feature is working, switch back to the develop
branch and merge the new feature:
git checkout develop
git merge local-storage
git branch -d local-storage
We’re not going to push the changes yet because we still have to add some end-to-end testing code with Detox.
Adding Detox tests
In this section, we’ll be setting up end-to-end testing for the app using Detox.
Setting up Detox
Start by following the Install Dependencies section on Detox’s Getting Started documentation.
Next, create a new branch off of the develop
branch:
git checkout develop
git checkout -b add-detox-test
Setting up Detox on Android
If you’re working on an Android app, you need to upgrade to Gradle 3 first because that’s what Detox is using. You can check the following files as your guide for upgrading to Gradle 3. Each line that has to do with the Gradle 3 upgrade is started with a “Gradle3” comment. You can find the commit here, and these are the files to update:
android/build.gradle
android/gradle/wrapper/gradle-wrapper.properties
If you’re following this tutorial wanting to apply it on your own projects, and you are using packages which uses a lower version of Gradle, you can actually fork the GitHub repo of those packages and update them to use Gradle 3.
Once you’re done updating the files, execute react-native run-android
on your terminal to check if everything is still running correctly. Don’t forget to launch a Genymotion emulator or Android emulator instance before doing so.
Once you’ve verified that the app is still running correctly, you can start installing Detox and Mocha:
yarn add detox@8.1.6 mocha@4.0.1 --dev
Next, you need to link Detox to your Android project. For that, you need to update the following files. All changes that have to do with linking Detox to the project starts with the “Detox” comment. You can find the commit here, and these are the files to update:
android/settings.gradle
android/build.gradle
android/app/build.gradle
android/app/src/androidTest/java/com/reactnativeci/DetoxTest.java
- create this.
Setting up Detox on iOS
For iOS, you don’t really need to do any additional configuration. Just make sure that you have the latest version of Xcode installed (or at least one of the more recent ones). This way, you can avoid having to deal with issues that only occurs when running older versions of Xcode.
Adding the tests
Update your package.json
file to include the detox
config. This allows you to specify which specific emulator or simulator to be used by Detox when running the tests as well as the command to execute for building the app on both platforms:
"detox": {
"configurations": {
"ios.sim.debug": {
"binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/reactnativeci.app",
"build": "xcodebuild -project ios/reactnativeci.xcodeproj -scheme reactnativeci -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
"type": "ios.simulator",
"name": "iPhone 5s"
},
"android.emu.debug": {
"binaryPath": "./android/app/build/outputs/apk/debug/app-debug.apk",
"build": "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
"type": "android.attached",
"name": "192.168.57.101:5555"
}
},
"test-runner": "mocha",
"specs": "e2e",
"runner-config": "e2e/mocha.opts"
}
The only things you need to change in the configuration above is the type
and name
under the ios.sim.debug
and android.emu.debug
.
If you’re using Genymotion like I am, you can keep the android.emu.debug
config in there. Just be sure to replace 192.168.57.101:5555
with the actual IP address that’s listed when you execute adb devices
while the Genymotion emulator is open.
If you’re using an Android emulator installed via Android Studio, go to the folder where Android SDK is installed. Once inside, go to the sdk/tools/bin
directory and execute ./avdmanager list avd
. This will list all of the available Android emulators. Simply copy the displayed name and use it as the value for the name
under android.emu.debug
:
If you’re using the iOS simulator, execute xcrun simctl list
to list all of the installed iOS simulators on your machine. The value on the left side (for example: iPhone 5s) is the one you put as the value for the name
:
Next, initialize the test code:
detox init -r mocha
This will create an e2e
folder in your project’s root directory. This folder contains the config and test files for running the tests.
Next, remove the contents of your e2e/firstTest.spec.js
file and add the following. This will test if all the functionality of the app is working:
describe("App is functional", () => {
beforeEach(async () => {
await device.reloadReactNative(); // reload the app before running each of the tests
});
it("should show loader", async () => {
await expect(element(by.id("loader"))).toExist(); // we're using toExist() instead of isVisible() because the ActivityIndicator component becomes invisible when a testID prop is passed in
});
it("should load cards", async () => { // assumes that if one card exists, then all the other cards also exists
await expect(element(by.id("card-Blaziken"))).toExist();
});
it("card changes state when it is clicked", async () => {
await element(by.id("card-Entei")).tap(); // not favorited by default
await expect(element(by.id("card-Entei-heart"))).toExist(); // should be marked as favorite
await element(by.id("card-Entei")).tap(); // clicking for a second time un-favorites it
await expect(element(by.id("card-Entei-heart-o"))).toExist(); // should not be marked as favorite
});
it("card state is kept in local storage", async () => {
await element(by.id("card-Entei")).tap(); // not favorited by default
await device.reloadReactNative(); // has the same effect of re-launching the app
await expect(element(by.id("card-Entei-heart"))).toExist(); // should still be favorited after app is reloaded
});
});
Since we don’t want Jest to be matching our newly created Detox tests, limit it to only look for tests inside the __tests__
directory:
// package.json
"jest": {
// current config here...
"testMatch": ["<rootDir>/__tests__/*"]
},
Once that’s done, we need to hook up the testID
to each of the components that the tests above are targeting. First, add it to the ActivityIndicator
:
// src/components/CardList.js
class CardList extends Component {
...
render() {
const { fetching, cards } = this.props;
return (
<View style={styles.container}>
<ActivityIndicator
size="large"
color="#333"
animating={fetching}
testID="loader"
/>
...
</View>
);
}
}
For the Card
component, we’re using the testID
supplied in the Icon
component to check whether the card is favorited or not. We’re simply appending the name of the Pokemon (text
) and the icon
used to determine this:
// src/components/Card.js
const Card = ({ image, text, is_favorite, action }) => {
const icon = is_favorite ? "heart" : "heart-o";
return (
<TouchableOpacity onPress={action} testID={"card-" + text}>
<View style={styles.card}>
...
<Icon
name={icon}
size={30}
color={"#333"}
testID={"card-" + text + "-" + icon}
/>
</View>
</TouchableOpacity>
);
}
Don’t forget to update the Jest snapshot as well:
yarn test -u
Commit the changes once you’re done:
git add .
git commit -m "add detox tests"
Run the tests locally
The final step before we get to play around with Bitrise is to run the tests. First, run the Jest snapshot test. This should succeed since we’re always updating the snapshots with yarn test -u
whenever we make changes to the components:
yarn test
As for Detox, start by running whichever platform you’re testing on:
react-native run-android
react-native run-ios
Next, run the tests. Confirm that the metro builder is running (react-native start
) and be sure to pass the --reuse
flag so that it will reuse the already installed app:
detox test -c ios.sim.debug --reuse
detox test -c android.emu.debug --reuse
Note that you can also try building the app with Detox and then test it directly:
detox build -c ios.sim.debug
detox build -c android.emu.debug
detox test -c ios.sim.debug
detox test -c android.emu.debug
The above method works for iOS, but I never got it to work on Genymotion. So it’s better to opt for the --reuse
option.
Once you’ve confirmed that all the tests pass and merge your changes to the develop
branch:
git checkout develop
git merge add-detox-test
git branch -d add-detox-test
Configure the build workflow
Now we’re ready to configure Bitrise to build the project and run the same tests that we’ve set up for the app.
Configure the build workflow for iOS
First, go to your app dashboard and select ReactNativeCI-iOS then go to the Settings tab. From there, update the Default branch to develop
and save the changes.
Next, go to the Workflows tab and select Stack. Select Xcode 9.4.x… as the default stack. This should automatically select this stack as the value for Workflow Specific Tasks as well. But if not, be sure to pick the same stack and save the changes:
The Stack is the type of machine where each of your workflows will be executed. In this case, we’re selecting Xcode 9.4 because it’s the latest stable version that’s currently available for iOS development. More importantly, it’s the same version of Xcode that I have on my local machine.
To ensure that your builds will be as smooth flowing as possible, always select a similar stack to your local machine. If that’s not possible, then select the one that’s only a version lower or higher than what you have.
Next, go back to the Workflows tab so we can configure each individual step for building the app. Delete everything else except for these steps and save the changes:
- Activate SSH key (RSA private key)
- Git Clone Repository
- Run npm command - rename this to “Install Packages”
After the Git Clone Repository step, create a new one called “Install detox dependencies”.
A modal window will pop-up asking you to select the step you want to add. Make sure that the ALL tab is selected, search for “script”, and click on the one which says “Script”:
As you can see, Bitrise has a bunch of pre-written steps. All you have to do is look for them and add it to your own workflow. But for things that don’t have a pre-written script, there are also steps that allow you to add them. One of those is the Script step which allows you to supply your own script.
Add the following script under the Script content field and save the changes:
#!/usr/bin/env bash
# fail if any commands fails
set -e
# debug log
set -x
echo "Installing Detox dependencies..."
npm install -g detox-cli
brew tap wix/brew
brew install applesimutils --HEAD
From the script above, you can see that these are the same commands you can find on Detox’s Getting Started guide to install Detox, so be sure to update these with the ones you find on that page in case it changes in the future.
If you scroll down a little bit, you will see the configuration for this script. Most of the time, you don’t really need to make any change to this one because Bitrise’s default config is already okay:
From the config above, the Working directory is $BITRISE_SOURCE_DIR
. By default, this points out to the root directory of your React Native project.
If you see something that starts with the dollar sign, it means that it’s an environment variable. In Bitrise, these can be set under the Env Vars tab. If you examine the values closely, you’ll see that it’s the same ones from when you have created this new app instance. This is where you can change them in case you messed up the selection earlier. If you notice any hard-coded values that you’re repeating over and over in each of your build steps, this is a good place to put them:
Note that you can’t find $BITRISE_SOURCE_DIR
anywhere in the Env Vars tab. This is because it’s one that’s set by Bitrise by default so it always points out to the same thing.
Right after the Install packages step, add a new script step called “Jest Snapshot test”. Put the following and save it:
#!/usr/bin/env bash
# fail if any commands fails
set -e
# debug log
set -x
# write your script here
echo "Running snapshot tests..."
yarn test
After the Jest Snapshot test step, add a new script step called “Build iOS app with Detox”:
#!/usr/bin/env bash
set -e
set -x
echo "Building iOS app..."
detox build -c ios.sim.debug
Lastly, add the script for running the end-to-end tests with Detox. Call the script “Test iOS app with Detox”:
#!/usr/bin/env bash
set -e
set -x
echo "Testing iOS app..."
detox test -c ios.sim.debug
Once that’s added, your workflow should now look something like this:
- Activate SSH key (RSA private key)
- Git Clone Repository
- Install Detox dependencies
- Install packages
- Jest Snapshot test
- Build iOS app with Detox
- Test iOS app with Detox
It’s a good practice to make each individual step only do one thing even though you can bring all the commands into a single script. Aside from keeping things lightweight and allowing you to easily debug your scripts, this also allows you to easily rearrange your steps (via drag and drop) and delete the ones you don’t need.
Configure build workflow for Android
If you’ve skipped to this section because you only want to build for Android, you should scan through the section above on configuring the build workflow for iOS because this section assumes you already know to configure the build workflow on Bitrise.
If you haven’t done so already, go to the settings tab of the ReactNativeCI-Android app and set its default branch to develop
.
Next, click on the Workflow tab and click on the Stack tab. This time, select Android & Docker, on Ubuntu 16.04 - LTS Stack as the default stack. This should give you the best environment for building an Android app with React Native. Don’t forget to save the changes once you’re done.
To make the configuration of the build workflow faster, instead of using the workflow editor, we’ll be using the bitrise.yml
file to configure the build. Copy the contents of the file from the GitHub repo then copy it to the editor in the bitrise.yml tab. Save the changes once you’re done:
Once the changes are saved, you can switch back to the Workflows tab to see the visual representation of the build workflow:
When you’re using the workflow editor, Bitrise actually updates the bitrise.yml
to match what you have on your workflow. This makes it really easy for developers to transfer a workflow that they have on an older app over to a newer app.
If you scroll all the way down on your workflow steps, you can see that we’re not running any end-to-end testing with Detox. This is because I couldn’t get the Detox tests to run on Android. The build is working, but running the app isn’t. Booting up an Android emulator takes a really long time so it defeats the purpose of building the app on a CI server because the build takes a long time to complete
Run the build on Bitrise
Now that you’ve fully configured your build workflow, you can now push all your changes to the repo. This will trigger a build on both the Android and iOS version of the app:
git push origin --all
Note that you can actually have different workflows for different build processes. In this tutorial, we’ve only configured the “primary” workflow which is the default build process that what we want to do everytime some changes is pushed into the repo. But you can also have a “deploy” workflow or a “testing” workflow, and the steps for that can be different from the one you have in your primary workflow.
Once the build is done, here’s what it will look like for the Android app:
And here’s what it will look like for iOS:
Run the build with Bitrise CLI
Another good thing about Bitrise is that you can run your builds using the Bitrise CLI. This is Bitrise’s open-source task runner for running your builds locally. You can follow the instructions on that page to setup Bitrise CLI.
Once you’ve setup Bitrise CLI, you can simply download your project’s bitrise.yml
file and copy it over to your React Native project’s root directory.
To run the build, use the bitrise run
command and append the name of the workflow you want to run:
bitrise run primary
If you find that the Bitrise CLI doesn’t meet your requirements, or you get errors that you don’t get while running the build on Bitrise, you can also make use the Bitrise Docker image. This allows you to run your builds locally using the same environment as the one used by Bitrise’s virtual machines.
Conclusion
That’s it! In this tutorial, you learned how to use Bitrise for a solid mobile continuous integration setup. Specifically, you learned how to set up a custom build workflow that runs Jest snapshot tests, Detox end-to-end test, and then build the app.
That also wraps up this series so I hope you’ve gained the necessary skills in setting up continuous integration for your React Native app.
You can find the code used in this series on its GitHub repo. The master
branch contains the final output for this entire series.
25 September 2018
by Wern Ancheta