Expo React Native CI/CD flow using CircleCI

Apr 19, 2021 React Native

Expo is a great abstraction layer around React Native which makes it a lot easier to setup React Native apps, from accessing native APIs to establishing CI/CD pipelines.

In this post, I will walk you through how to setup CI/CD flow for Expo React Native using Circle CI.

TL;DR

Expo CI/CD flow

The diagram above describes the overall flow of the CI/CD flow. The Android builds are not included for simplicity.

Here's the complete config.yml file for this flow.

version: 2.1
defaults: &defaults
  working_directory: ~/app
  docker:
    # the Docker image with Cypress dependencies
    - image: cypress/base:12.14.0
      environment:
        ## this enables colors in the output
        TERM: xterm

commands:
  restore-cache-and-install-dependencies:
    description: "Restore cache and install dependencies"
    steps:
      - restore_cache:
          key: v2-deps
      - run:
          name: Install Dependencies
          command: npm ci
      - save_cache:
          key: v2-deps-{{ .Branch }}-{{ checksum "package-lock.json" }}
          paths:
            - node_modules
            - ~/.npm
            - ~/.cache
  publish_to_expo:
    description: "Publish JS bundle to Expo"
    steps:
      - checkout
      - restore-cache-and-install-dependencies
      - run:
          name: Publish to Expo
          command: npx expo-cli publish --non-interactive --max-workers 1 --release-channel $EXPO_RELEASE_CHANNEL

  build_binaries:
    description: "Build native binaries"
    steps:
      - checkout
      - restore-cache-and-install-dependencies
      - run:
          name: Build Android
          command: npx expo build:android -t apk --non-interactive --release-channel $EXPO_RELEASE_CHANNEL --no-wait
      - run:
          name: Build iOS
          command: npx expo build:ios -t archive --non-interactive --release-channel $EXPO_RELEASE_CHANNEL --no-wait

jobs:
  unit_test:
    <<: *defaults
    steps:
      - checkout
      - restore-cache-and-install-dependencies
      - run:
          name: Run ESLint
          command: npm run lint
      - run:
          name: Run unit tests
          command: npm run test

  publish_feature_to_expo:
    <<: *defaults
    environment:
      EXPO_RELEASE_CHANNEL: << pipeline.git.branch >>
    resource_class: xlarge
    steps:
      - publish_to_expo

  publish_to_expo_staging:
    <<: *defaults
    environment:
      EXPO_RELEASE_CHANNEL: staging
    resource_class: xlarge
    steps:
      - publish_to_expo

  build_binaries_staging:
    <<: *defaults
    environment:
      EXPO_RELEASE_CHANNEL: staging
    steps:
      - build_binaries

  publish_to_expo_production:
    <<: *defaults
    environment:
      EXPO_RELEASE_CHANNEL: production
    resource_class: xlarge
    steps:
      - publish_to_expo

  build_binaries_production:
    <<: *defaults
    environment:
      EXPO_RELEASE_CHANNEL: production
    steps:
      - build_binaries

workflows:
  version: 2
  build-deploy:
    jobs:
      - unit_test
      - approval:
          type: approval
          requires:
            - unit_test
      - publish_feature_to_expo:
          context: app-name-staging
          requires:
            - approval
          filters:
            branches:
              ignore: master
      - publish_to_expo_staging:
          context: app-name-staging
          requires:
            - unit_test
          filters:
            branches:
              only: master
      - build_binaries_staging:
          context: app-name-staging
          requires:
            - approval
          filters:
            branches:
              only: master
      - publish_to_expo_production:
          context: app-name-production
          requires:
            - unit_test
          filters:
            tags:
              only: /^v.*/
      - build_binaries_production:
          context: app-name-production
          requires:
            - approval
          filters:
            tags:
              only: /^v.*/

On feature branch

When you push to a feature branch, it will run tests and trigger the Expo publish which will build JS bundle and deploy to Expo CDN. The publish command looks like this.

npx expo-cli publish --non-interactive --max-workers 1 --release-channel $EXPO_RELEASE_CHANNEL

Note that you need to set the EXPO_TOKEN environment variable in the project settings for authorizing the expo command. You can generate the access token in the Expo dashboard.

Also, it specifies the release channel with the $EXPO_RELEASE_CHANNEL environment variable.

For feature branches, the release channel corresponds to the branch name. In the job, you can set the environment variable like this.

publish_feature_to_expo:
  <<: *defaults
  environment:
    EXPO_RELEASE_CHANNEL: << pipeline.git.branch >>
  resource_class: xlarge
  steps:
    - publish_to_expo

Once the JS bundle is published to Expo, you can test it with Expo Go app.

On merging to master branch

When a feature branch is merged to the master branch, it will run tests, publish JS bundle to Expo on the staging channel, and build native binaries for iOS and Android.

Note that you need an Apple Developer account for iOS builds.

Once the native binary has been built, you can download the .ipa and .apk file from Expo and upload them to TestFlight and Google Play Console for testing.

I added the approval process as you don't need to build native binaries every time. Since most updates can be done through OTA updates, you need to rebuild native binaries only when you change native metadata like the app's name or icon or upgrade Expo SDK.

If you want to automate uploading native binaries, look at EAS Submit (I haven't tried it yet).

On release tag

When a release tag is created, it will run tests, publish JS bundle to Expo on the production channel, and build native binaries for iOS and Android.

The only differece from the previous flow is that it publishes to the production channel.

Environment variables

For most apps, you need to define environment variables for each release channel (e.g. staging, production).

CircleCI contexts allows us to define different environment variables per environment. You can specify which context to use in the workflow like this.

context: app-name-staging

Wrap up

With Expo, it's straightforward to setup CI/CD pipeline for React Native apps.

If you've got any questions or comments, DM me on Twitter @avosalmon.

avatar

About Me

I am a software engineer from Japan and currently based in Singapore. Over the past 5 years, I've been creating e-commerce and FinTech web applications using Laravel, Angular, and Vue.js. Making clean, maintainable, and scalable software with agile methodology is one of my biggest passions. Im my spare time, I play the bass and enjoy DIY.