Published on, Time to read
🕒 12 min read

Setting up a CI/CD for React Native project with Fastlane

Setting up a CI/CD for React Native project with Fastlane

Introduction

So, you have a React Native project and you want to automate the deployment process.

You've probably heard about Fastlane, a tool that helps to automate the deployment process for mobile apps, and maybe you've even tried it. But failed a comple of times and gave up reading the documentation.

So, take a deep breath, brew your favorite coffee or tea. I have a recipe for you.

Prepare environment

I am working on MacBook, so the following steps are related to MacOS.

First of all, let's check if our system is ready for the Fastlane. Install the Xcode Command Line Tools by running the following command in the terminal:

xcode-select --install

you may see an "error" stating that the command line tools are already installed. That's fine.

Now, we must check which version of Ruby we have installed. Fastlane requires Ruby v2.5.0 or above (but below v3). You can check the version by running:

ruby -v

It's almost certain, that you have version 2.7.2p137 which is the latest stable version at the time of writing this article. If you have any other version I recommend using rvm to manage Ruby versions. It's is similar to nvm for Node.js so might look familiar to you.

And the last step is to install Bundler (a package manager for Ruby like npm for Node.js). You can do it by running:

gem install bundler

Setup iOS app

Prepare iOS app

Go to your iOS project directory

cd ios

and create a new file called Gemfile

touch Gemfile

with the following content:

source "https://rubygems.org"

gem "fastlane"

run bundle update and add Gemfile and Gemfile.lock to your version control system (e.g. git).

Prepare Fastlane for iOS

In same directory initiate Fastlane by running:

bundle exec fastlane init

You will see the following output:

fastlane init output
Image: fastlane init output

Select option 2 (enter 2 in prompt and hit Enter), because we are willing to setup a TestFlight deployments.

After that, you will be asked to provide your Apple ID and password. You can also provide a two-factor authentication code if you have it enabled. Read carefully the output and follow the instructions. Fastlane will create ios/fastlane directory with two files Appfile and Fastfile. Commit these files to your version control system, we will come back to them later.

Prepare App Signing

In order to distribure your app to the Apple App Store, you need to have a valid certificate and provisioning profiles. Fastlane can help us and our team to manage them automatically.

First of all revoke all existing provisioning profiles related to this app from Apple Developer account. Then run the following command:

bundle exec fastlane match init

And follow the prompt. I will use AWS S3 bucket to store the certificates and profiles, but you can choose the option that is the most convenient for you. The new file Matchfile will be created in the ios/fastlane directory. Commit this file to your version control system.

Now create an App-Specific Password for the Apple Account you use to sign in into Apple Developer portal at Apple Account and store the key in some secure place. You wount be able to see it again.

Since I am using S3 bucket to store the certificates and profiles, I created a new bucket at AWS console with no public access, secured and encrypted. After that I created a user (at IAM) with read/write permissions to this bucket only. It's policy looks as following:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "s3:GetObjectAttributes",
        "s3:ListBucket",
        "s3:DeleteObject"
      ],
      "Resource": ["arn:aws:s3:::BUCKET_NAME", "arn:aws:s3:::BUCKET_NAME/*"]
    },
    {
      "Sid": "VisualEditor1",
      "Effect": "Allow",
      "Action": "s3:GetAccessPoint",
      "Resource": "*"
    }
  ]
}

And create an Access Key for this user, so I can acess the bucket from Fastlane. Do not forget to save access key and secret key in a secure place, because you won't be able to see the secret key again.

If you decided to use other provider to store your certificates and profiles, you can read docs here

The last step is to generate Apple App Store API key to have a possibility to upload an app to the App Store and TestFlight. To do this, open App Store Connect, go to Users and Access and create a new Team key. You will need Key ID, Issuer ID and Key Content. Store them in a secure place, because you won't be able to download the Key Content again.

At this point we have all the necessary credentials and objects prepared, so we can take a 5 min break, and then continue with the next steps 🧘‍♂️

Hide all the secrets

Since we do not want to compromise our credentials for any occasion, let's create an .env file in the ios/fastlane directory and add the following values:

LC_ALL=en_US.UTF-8
LANG=en_US.UTF-8

APP_BUNDLE_ID=< YOUR_APP_ID, e.g. com.perfectapp.for.me >
APP_APPSTORE_APPLE_ID=< App ID from App Store Connect. The app must be created >
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=<aaaa-bbbb-cccc-dddd>
APPSTORE_CONNECT_TEAM_ID=0123456789
APPLE_DEVELOPER_TEAM_ID=ABCDEFGHIJ
APPLE_DEVELOPER_ID=< your email for Apple Developer account >


S3_BUCKET=< S3 bucket name >
S3_REGION=< S3 bucket region, e.g. us-east-1 >
S3_AWS_ACCESS_KEY_ID=< s3 user access key >
S3_AWS_SECRET_ACCESS_KEY=< s3 user secret access key >

APPSTORE_API_KEY_ISSUER_ID=< isser-id-that-look-like-this >
APPSTORE_API_KEY_KEY_ID=ABCDEFGHIJ
APPSTORE_API_KEY_KEY_CONTENT="<-----BEGIN PRIVATE KEY-----\nkey_line_1\nkey_line_2\nkey_line_3\nkey_line_4\n-----END PRIVATE KEY----->"

# The following line ensures that the AWS SDK does not use SSO credentials from the AWS CLI
# if the one is configured. This is important for the AWS SDK to work correctly with the Fastlane.
AWS_SDK_CONFIG_OPT_OUT=true

# The following line disables the automatic upload of the dSYM files to Sentry
# Add this line if you are using Sentry and do not want to upload dSYM files automatically
# Useful for local builds, that will not be distributed to the App Store
SENTRY_DISABLE_AUTO_UPLOAD=true

Warning

Do not commit .env file to your version control system. Add it to .gitignore file.

But you can commit the .env.example file with the same content as .env but with empty values. It will help other developers and future self to understand which values are required.

And modify the Appfile and Matchfile to use these environment variables instead of hardcoded values. We will come back to Fastfile later.

Appfile:

app_identifier(ENV["APP_BUNDLE_ID"]) # The bundle identifier of your app
apple_id(ENV["APPLE_DEVELOPER_ID"]) # Your Apple Developer Portal username

itc_team_id(ENV["APPSTORE_CONNECT_TEAM_ID"]) # App Store Connect Team ID
team_id(ENV["APPLE_DEVELOPER_TEAM_ID"]) # Developer Portal Team ID

Matchfile:

# The docs are available on https://docs.fastlane.tools/actions/match

storage_mode("s3")
s3_bucket(ENV["S3_BUCKET"])
s3_region(ENV["S3_REGION"])
s3_access_key(ENV["S3_AWS_ACCESS_KEY_ID"])
s3_secret_access_key(ENV["S3_AWS_SECRET_ACCESS_KEY"])

type("development") # The default type, can be: appstore, adhoc, enterprise or development

app_identifier([ENV["APP_BUNDLE_ID"]])
username(ENV["APPLE_DEVELOPER_ID"]) # Your Apple Developer Portal username
team_id(ENV["APPLE_DEVELOPER_TEAM_ID"]) # Developer Portal Team ID

Are we there yet?

Finally, after all the preparations we can use match to generate all the necessary certificates and profiles for our app signing. Run the following command:

bundle exec fastlane match

You will be asked for the passphrase to encrypt/decrypt the certificates and profiles. Store it in a secure place! If you did everything correctly and according to the instructions provided, you will see the following output:

INFO All required keys, certificates and provisioning profiles are installed 🙌

Open xCode and check if the certificates and profiles are installed correctly. You can find them in the Signing & Capabilities tab of your project settings. If not, make shure that you unchecked the Automatically manage signing option and select the freshly generated profiles.

Now, lets update the Fastfile to build the app locally (for testing purposes).

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :beta do
    # UI.message "Setup App Store Connect API Key"
    app_store_connect_api_key(
      key_id: ENV["APPSTORE_API_KEY_KEY_ID"],
      issuer_id: ENV["APPSTORE_API_KEY_ISSUER_ID"],
      key_content: ENV["APPSTORE_API_KEY_KEY_CONTENT"],
      duration: 1200, # optional (maximum 1200)
      in_house: false # optional but may be required if using match/sigh
    )
    # UI.message "Getting the latest certificates for an iOS app"
    match(type: "appstore", readonly: true)
    # UI.message "Incrementing the build number"
    # uncomment the following line if you want to increment the build number automatically
    # increment_build_number(xcodeproj: "YOUR_WORKSPACE.xcodeproj")
    # UI.message "Building the app"
    # 🚨 ATTENTION 🚨
    # PLEASE MAKE SURE UPDATE THE YOUR_WORKSPACE and YOUR_SCHEME values according to your project settings
    # 🚨 ATTENTION 🚨
    build_app(workspace: "YOUR_WORKSPACE.xcworkspace", scheme: "YOUR_SCHEME")
  end
end

And run the following command:

bundle exec fastlane beta

After a couple of minutes, you will see the following output:

[21:33:18]: Successfully exported and compressed dSYM file
[21:33:18]: Successfully exported and signed the ipa file:
[21:33:18]: /PATH_TO_PROJECT/ios/APP_NAME.ipa

+------------------------------------------------+
|                fastlane summary                |
+------+---------------------------+-------------+
| Step | Action                    | Time (in s) |
+------+---------------------------+-------------+
| 1    | default_platform          | 0           |
| 2    | app_store_connect_api_key | 0           |
| 3    | match                     | 1           |
| 4    | build_app                 | 359         |
+------+---------------------------+-------------+

[21:33:19]: fastlane.tools just saved you 6 minutes! 🎉

It means we are ready to move to automantion with GitHub Actions 🚀

Setup CI/CD with GitHub Actions

I used to work with GitHub Actions. It's a great tool to automate the CI/CD process for your project. And provides a lot of possibitlies for free.

If its your first time with GitHub Actions, I recommend reading the official documentation.

Prepare GitHub Actions

In your project root create a folder .github/workflows and create a new file ios.yml with the following content:

name: ios-app-testflight
run-name: Build and deploy iOS app to TestFlight
env:
  LC_ALL: en_US.UTF-8
  LANG: en_US.UTF-8

permissions:
  id-token: write
  contents: read
on:
  workflow_dispatch:
  push:
    # remove `paths` if you want to run the workflow on every push or update it accordingly to your repository structure
    # I use monorepo and my mobile app is located in `apps/mobile` directory
    paths:
      - "apps/mobile/**"
    branches:
      - main
jobs:
  install:
    name: Install dependencies
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup_node
      - uses: ./.github/actions/install_with_cache
  build:
    name: Build and deploy iOS app to TestFlight
    needs: install
    runs-on: macos-14
    env:
      FASTLANE_OPT_OUT_USAGE: true
      S3_BUCKET: ${{ vars.S3_BUCKET }}
      S3_REGION: ${{ vars.S3_REGION }}
      S3_AWS_ACCESS_KEY_ID: ${{ vars.S3_AWS_ACCESS_KEY_ID }}
      S3_AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_AWS_SECRET_ACCESS_KEY}}
      APP_BUNDLE_ID: ${{ vars.APP_BUNDLE_ID }}
      APP_APPSTORE_APPLE_ID: ${{ vars.APP_APPSTORE_APPLE_ID }}
      FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD}}
      APPSTORE_CONNECT_TEAM_ID: ${{ vars.APPSTORE_CONNECT_TEAM_ID }}
      APPLE_DEVELOPER_TEAM_ID: ${{ vars.APPLE_DEVELOPER_TEAM_ID}}
      APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }}
      MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
      APPSTORE_API_KEY_KEY_ID: ${{ vars.APPSTORE_API_KEY_KEY_ID }}
      APPSTORE_API_KEY_ISSUER_ID: ${{ vars.APPSTORE_API_KEY_ISSUER_ID }}
      APPSTORE_API_KEY_KEY_CONTENT: ${{ secrets.APPSTORE_API_KEY_KEY_CONTENT}}
      # add other environment variables if needed, f.e. SENTRY_AUTH_TOKEN, or API_URL etc.
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node.js
        uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # version 4.0.2
        with:
          node-version: 20
          # edit `yarn` to `npm` if you use npm instead
          cache: "yarn"
      - name: Install dependencies
        uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # version 4.0.2
        id: yarn-cache
        with:
          path: |
            node_modules
            **/node_modules
          # edit `yarn.lock` to `package-lock.json` if you use npm instead
          key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
      - if: steps.yarn-cache.outputs.cache-hit != 'true'
        # edit `yarn install --immutable` to `npm ci` if you use npm instead
        # edit `yarn install --immutable` to `yarn install --frozen-lockfile` if you use yarn@v1
        run: yarn install --immutable
      - name: Config ruby
        uses: ruby/setup-ruby@bc2ba926aade0711935b50bcb1b9ccdb5fde2dda
        with:
          ruby-version: 2.7
          bundler-cache: true
      - name: Install gems for iOS
        if: runner.os == "macos"
        working-directory: ./ios
        shell: bash
        run: bundle install
      - name: Install pods for iOS
        if: runner.os == "macos"
        working-directory: ./ios
        shell: bash
        run: bundle exec pod install
      - name: Build and distribute iOS app
        working-directory: ./ios
        shell: bash
        run: bundle exec fastlane beta

Important

Now you must go to your GitHub repository settings and add all the variables and secrets that are used in the ios.yml file. Values are the same that we used in the .env file previously.

After that, lets update the Fastfile with the following content:

default_platform(:ios)

platform :ios do
  desc "Push a new beta build to TestFlight"
  lane :beta do
    # UI.message "Setup CI environment"
    setup_ci if ENV["CI"]
    # UI.message "Setup App Store Connect API Key"
    app_store_connect_api_key(
      key_id: ENV["APPSTORE_API_KEY_KEY_ID"],
      issuer_id: ENV["APPSTORE_API_KEY_ISSUER_ID"],
      key_content: ENV["APPSTORE_API_KEY_KEY_CONTENT"],
      duration: 1200, # optional (maximum 1200)
      in_house: false # optional but may be required if using match/sigh
    )
    # UI.message "Getting the latest certificates for an iOS app"
    match(type: "appstore", readonly: true)
    # UI.message "Incrementing the build number"
    # uncomment the following line if you want to increment the build number automatically
    # increment_build_number(xcodeproj: "YOUR_WORKSPACE.xcodeproj")
    # UI.message "Building the app"
    # 🚨 ATTENTION 🚨
    # PLEASE MAKE SURE UPDATE THE YOUR_WORKSPACE and YOUR_SCHEME values according to your project settings
    # 🚨 ATTENTION 🚨
    build_app(workspace: "YOUR_WORKSPACE.xcworkspace", scheme: "YOUR_SCHEME")
    # UI.message "Uploading the app to TestFlight"
    upload_to_testflight(
      # uncomment this line if you want to specify the TestFlight testers group to distribute the app to
      # groups: "OneMoment Team",
      # comment this line if you want to wait for the build processing (will increase the GitHub Action time and cost)
      skip_waiting_for_build_processing: true,
      apple_id: ENV["APP_APPSTORE_APPLE_ID"],
      # uncomment this line if you want to distribute the app to external testers
      # distribute_external: true,
      # update the changelog text according to your needs, but it's required for the TestFlight distribution
      changelog: "The new version of the development build. Full changelog is available in the release notes."
    )
  end
end

We are ready to push the changes to the repository and see the magic happen 🪄

So commit the changes and push them to the repository.

If you follwed the instructions, you should see the new workflow running in the Actions tab of your repository. After a couple of minutes, you will see the new build in the TestFlight.

Now every commit to main branch will build and distribute the app to the TestFlight automatically. 🚀

In the next article, I will show you how to automate the deployment to the App Store and Google Play Store.