How to build and deploy React Native applications with Fastlane

A detailed look at our internal Fastlane setup, operating on cross-platform React Native projects as a team, integrating the CI/CD environment and CLI operations.

Introduction

React Native is a powerful framework for building mobile, desktop and even web applications, and with its CLI you can run a development version of a mobile app in an emulator or connected physical device almost with zero conf. Unfortunately, when you want to build and deploy for production, deal with code signing, certificates and keys, and share the development process across your team, it doesn’t offer a solution out of the box, and you still need to use platform-specific IDE like Xcode and Android Studio.

But there’s an open source tool that fills this gap and provides a complete automation for the whole process, starting from the development build to the signed production bundle: Fastlane.

In this post I will show you our internal Fastlane setup that lets us operate on cross-platform React Native projects as a team, integrate the CI/CD with GitHub Actions, and do almost everything from the CLI.

Note: this setup is tested on React Native projects, but should be easily adaptable to any mobile framework or native projects.

Let’s start.

Installation

So first of all if you want to be cross-platform and build for iOS and Android, you’re gonna need a Mac so this post assumes that you are on a macOS and that you have everything set-up for a manual build with Xcode and Android Studio.

The key tool here is fastlane so my advice is to read the docs for a complete setup guide (https://docs.fastlane.tools/) but in short you need to install Xcode command-line tools and the fastlane tool.

> xcode-select --install
> brew install fastlane

And then cd to your project root and run:

> fastlane init

This will create a fastlane folder with an initial Fastfile, which is the config file for the tool.

Environment setup

Since you want to build across different local machines and CI environments, you need to setup a .env file that fastlane is able to read (https://docs.fastlane.tools/advanced/other/)

Here’s the template for a .env file that you should completely fill with your project’s specs.

KEYCHAIN_NAME=***.keychain
KEYCHAIN_PASSWORD=
FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD=
EXPORT_METHOD=
IOS_PROJECT_PATH=
WORKSPACE_PATH=
APP_ID=
APPSTORE_USERNAME=
TEAM_ID=
TEAM_NAME=
OUTPUT_PATH=
SCHEME=
BUILD_PODS=true
PODS_PATH=ios/Podfile
UPDATE_TARGETS=
MATCH_PASSWORD=
MATCH_GIT_URL=
MATCH_GIT_BASIC_AUTHORIZATION=
MATCH_BUILD_TYPE=development
ANDROID_PROJECT_PATH=android
KEYSTORE_PATH=
KEYSTORE_PASSWORD=
KEYSTORE_ALIAS=

Each variable should be pretty self-explanatory, for some of them we’ll see more details later.

Don’t commit the .env file! You can commit the template, but this file contains secrets that should be handled with your CI secrets management.

iOS

Storing and sharing certificates

In order to create a signed build for iOS you need to use your Apple certificates, but you don’t want to create them manually and store them only in your local machine, nor you want to use Xcode’s automatic signing because this would mean that you cannot share them with your team or in CI, so you’ll use a powerful tool provided by fastlane that creates and sync your Apple certificates across builds: match.

If you read the docs you can see that match supports Git repo, Google Cloud or Amazon S3 for storing the certificates and identities. For this post you will use Git.

Initialize match with:

> fastlane match init

This will create a Matchfile under your fastlane folder, but you can overwrite it with this one:

git_url(ENV["MATCH_GIT_URL"])

storage_mode("git")

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

app_identifier(ENV["APP_ID"])
username(ENV["APPSTORE_USERNAME"]) # Your Apple Developer Portal username

keychain_name(ENV["KEYCHAIN_NAME"])
keychain_password(ENV["KEYCHAIN_PASSWORD"])

team_id(ENV["TEAM_ID"])

# For all available options run `fastlane match --help`
# Remove the # in the beginning of the line to enable the other options

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

Follow these steps:

  • Create a new keychain and fill the .env file with keychain name and password
  • Create a private remote git repo (i.e. in GitHub)
  • Create a private token and save base64 encrypted (echo -n user:token | base64) username:token in env file as MATCH_GIT_BASIC_AUTHORIZATION
  • Configure env file with the URL of the git repo in MATCH_GIT_URL
  • Ensure the env file is filled in every variable regarding app name, apple account, team id, passwords
  • From your project root, run fastlane match

Now you should have new certificates and provisioning profiles created in your Apple developer account and stored in your private git repo by match. You are now ready to build your app with fastlane.

Setup the Fastfile

The Fastfile is a ruby file containing fastlane tasks (called lanes) that you can run individually.

Your first task should be a development build that produces a .ipa file that you can share with your team, or upload to a service like Browserstack App Live.

Something like this:

default_platform(:ios)

platform :ios do
  before_all do
    unlock_keychain(
      path: ENV['KEYCHAIN_NAME'],
      password: ENV['KEYCHAIN_PASSWORD']
    )

    if ENV["BUILD_PODS"] == 'true'
      cocoapods(
        podfile: ENV["PODS_PATH"],
        use_bundle_exec: false
      )
    end
  end

	desc "Export ipa"
  lane :export_ipa do
    use_workspace = !ENV['WORKSPACE_PATH'].empty?

    match(type: "development")

    build_app(
      workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
      project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
      configuration: ENV['CONFIGURATION'],
      scheme: ENV['SCHEME'],
      output_directory: File.dirname(ENV['OUTPUT_PATH']),
      output_name: File.basename(ENV['OUTPUT_PATH']),
      clean: true,
      export_method: ENV['EXPORT_METHOD'],
      export_team_id: ENV['TEAM_ID'],
    )
  end
end

You see that we are using a lot of our env variables here, so this is totally dependent on the environment. Let’s break it down to steps.

  • First we configure the default platform and the lane platform
  • In a before_all block, so before for each run, we unlock the keychain and if our env tells us to BUILD_PODS, then we use the fastlane command cocoapods to basically run pod install
  • We create the lane export_ipa.
  • use_workspace is a block variable that will be used later
  • Then we tell match to set the build type to development, so it will use our development profile
  • The last step is the actual build command

Building locally with fastlane

You are ready for your first build. Run:

> fastlane export_ipa

The build process now starts and you will see tons of logs in your command-line. Hopefully no errors 🙂

And finally…

...
*fastlane.tools finished successfully* 🎉

You should find your .ipa file in your project root!

Uploading a signed production build to TestFlight

So now that you have a working development build command, you can also runa production, signed build that you can upload to the appstore, test with TestFlight and then submit for review and publishing, all without even opening Xcode and from whatever local machine.

In order to do this you must simply fill the FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD with the one generated from your Apple account page as we said in a note before.

Then, you need the appropriate lane in your Fastfile, so add this one to the platform :ios section:

desc "Upload to TestFlight"
lane :upload_testflight do
  use_workspace = !ENV['WORKSPACE_PATH'].empty?

  match(type: "appstore")

  increment_build_number(
    xcodeproj: ENV['IOS_PROJECT_PATH'],
    build_number: (latest_testflight_build_number(
      app_identifier: ENV["APP_ID"],
      username: ENV["APPSTORE_USERNAME"],
      team_name: ENV["TEAM_NAME"],
    ) + 1).to_s,
  )

  build_app(
    workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
    project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
    configuration: "Release",
    scheme: ENV['SCHEME'],
    output_directory: File.dirname(ENV['OUTPUT_PATH']),
    output_name: File.basename(ENV['OUTPUT_PATH']),
    clean: true,
    include_bitcode: true,
    export_method: "app-store",
    export_team_id: ENV['TEAM_ID'],
  )

  upload_to_testflight(
    ipa: File.join(File.dirname(ENV['OUTPUT_PATH']), File.basename(ENV['OUTPUT_PATH'])),
    username: ENV['APPSTORE_USERNAME'],
    app_identifier: ENV['APP_ID'],
    team_name: ENV['TEAM_NAME'],
    skip_waiting_for_build_processing: true,
  )
end

You can spot some differences with the development build.

  • The type in match is now appstore, so Match will use the production profile and certs
  • We added increment_build_number that basically fetches the latest build number from TestFlight and then increments it by 1
  • We then build and sign using build_app as before but with a Release configuration and app-store export method
  • Finally we upload the build using upload_to_testflight
    • Note: skip_waiting_for_build_processing means that fastlane will end right after the upload, if you don’t set it or set it to false the process will wait until the build is completely processed on TestFlight

And that’s it, you can simply run

> fastlane upload_testflight

and your app should be uploaded to the App Store!

Creating a new release on the App Store

Fastlane is a really powerful tool. It even allows you to completely control the review and publish process of your app completely from the command line.

This is not the purpose of this blog post, but I want to share a lane that creates a new release for your app on App Store, that you can use as a starting point to craft your own:

desc "Release ipa"
lane :release do
  deliver(
    app_identifier: ENV["APP_ID"],
    username: ENV["APPSTORE_USERNAME"],
    team_name: ENV["TEAM_NAME"],
    build_number: (latest_testflight_build_number(
      app_identifier: ENV["APP_ID"],
      username: ENV["APPSTORE_USERNAME"],
      team_name: ENV["TEAM_NAME"],
    )).to_s,
    precheck_include_in_app_purchases: false,
    submit_for_review: false,
    force: true, # Skip HTMl report verification
    skip_screenshots: true,
    skip_binary_upload: true,
    submit_for_review: true,
    # Comes from https://github.com/fastlane/fastlane/issues/5542#issuecomment-254201994
    submission_information: {
      add_id_info_serves_ads: false,
      add_id_info_tracks_action: false,
      add_id_info_tracks_install: false,
      add_id_info_uses_idfa: false,
      content_rights_has_rights: true,
      content_rights_contains_third_party_content: true,
      export_compliance_platform: 'ios',
      export_compliance_compliance_required: false,
      export_compliance_encryption_updated: false,
      export_compliance_app_type: nil,
      export_compliance_uses_encryption: false,
      export_compliance_is_exempt: false,
      export_compliance_contains_third_party_cryptography: false,
      export_compliance_contains_proprietary_cryptography: false,
      export_compliance_available_on_french_store: false
    },
  )
end

Then running

> fastlane release

should create a new release from your latest uploaded build and submit it for review.

As you can see, you can configure pretty much any aspect of the submission process. You can even add screenshots and descriptions!

Building remotely in GitHub CI

So now you have a working local build, and since you have everything configured as environment variables and command-line tools you should be able to implement the same build using your preferred CI/CD system.

But if you are using GitHub, you can use an Action we created that let’s you easily and securely build remotely. It even uploads your build to Browserstack App Live if you want!

Here you can find our action: https://github.com/sparkfabrik/ios-build-action

So you can simply create a new action template and use this, but this needs a couple more steps because you need to download the Match certificates, decrypt them and upload them as secrets to your GitHub project.

Follow these steps:

  • Add sparkfabrik/ios-build-action to your GitHub Action, example:

    - name: Build and deploy app
      uses: sparkfabrik/ios-build-action@v1.2.0
      with:
        project-path: ${{ secrets.PROJECT_PATH }}
        p12-key-base64: ${{ secrets.P12_KEY_BASE64 }}
        p12-cer-base64: ${{ secrets.P12_CER_BASE64 }}
        mobileprovision-base64: ${{ secrets.MOBILEPROVISION_BASE64 }}
        team-id: ${{ secrets.TEAM_ID }}
        workspace-path: ${{ secrets.WORKSPACE_PATH }}
        export-method: ${{ secrets.EXPORT_METHOD }}
        output-path: ${{ github.sha }}.ipa
        configuration: Debug
        update-targets: |
          MyApp
          YourApp      
        disable-targets: App
        scheme: MyApp
        build-pods: true
        pods-path: 'ios/Podfile'
        browserstack-upload: true
        browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
        browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
    
  • Add the following secrets to GitHub (just copy / paste from your env file)

    • PROJECT_PATH (in env file it’s IOS_PROJECT_PATH)
    • TEAM_ID
    • WORKSPACE_PATH
    • EXPORT_METHOD
    • CERTIFICATE_PASSWORD (in env file it’s MATCH_PASSWORD)
    • CODE_SIGNING_IDENTITY (should be iPhone Developer)
    • Optional: BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY (only if you want to upload to BrowserStack)
  • Clone locally the private git repo where you stored the Match certificates

  • Decrypt the .cer and .p12 files in /certs/(development|appstore)/ and .mobileprovision in /profiles/(development|appstore)/ with the following command:

    • openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.cer -out ios-build.cer -a -d
    • openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.p12 -out ios-build.p12 -a -d
    • openssl aes-256-cbc -k $MATCH_PASSWORD -in $YOURCERT.mobileprovision -out ios-build.mobileprovision -a -d
    • $MATCH_PASSWORD is the same password you configured in env file
  • Copy and paste the .p12, .cer and .mobileprovision files in base64 in github in the corresponding GitHub secrets (examples using pbcopy):

    • P12_KEY_BASE64: base64 ios-build.p12 | pbcopy
    • P12_CER_BASE64: base64 ios-build.cer | pbcopy
    • MOBILEPROVISION_BASE64: base64 ios-build.mobileprovision | pbcopy

That’s it for the iOS build, let’s move to the Android one which is waaaay easier 😀

Android

Android build is easier because gradle is already a powerful tool for making command-line builds, so fastlane can basically act as a wrapper. React Native comes with a pre-configured gradle file that just works and produces development and production builds.

So you only need to wrap gradle tasks in fastlane lanes, providing the correct configurations, and sign the app with the new app signing system that Android offers.

Build unsigned production APK

For a development, unsigned build, you just need to fill the env variable ANDROID_PROJECT_PATH (in React Native this is android) and add this lane to your Fastfile:

platform :android do
  desc "Assemble release APK"
  lane :assemble do
    gradle(
      task: "clean assembleRelease",
      project_dir: ENV["ANDROID_PROJECT_PATH"],
      flags: "--no-daemon",
    )

    APK_LOCATION = "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}"
    puts "APK location: #{APK_LOCATION}"
  end
end

We are using the gradle command, telling gradle to execute the clean task (so we have a clean build folder) and then the assembleRelease task, which produces an unsigned production build as an APK file. Then we print (using puts) in console the location of that file.

Of course you need to have gradle executable in your command-line environment, as long ad Java and everything Android needs, but if you build with React Native or other frameworks at this point you should already have everything set-up.

Now, you can just launch:

> fastlane android assemble

And have your unsigned APK (the file location is printed before the final fastlane summary table) ready to be tested on a local device or uploaded to BrowserStack.

Note that here we assume that ios is the default platform, so in order to run assemble you must first specify the correct platform, which is android.

Build signed production bundle

In order to produce the signed bundle (.aab), first you need to prepare your app for signing, following the official guide here: https://developer.android.com/studio/publish/app-signing

When you have every file, save the keystore file, pepk key, pem certificate and credentials (alias, passwords) to a local secure folder, and upload it to a safe location (eg. private git repo, gcloud bucket, …). Unfortunately, Fastlane doesn’t provide a tool like Match for managing Android keys so you need to store and share them on your own.

Then you need to fill the following vars in your .env file:

KEYSTORE_PATH= (path to the .jks file)
KEYSTORE_PASSWORD= (the keystore password)
KEYSTORE_ALIAS= (the keystore alias)

The lane for your production build is this:

lane :bundle_production do
	increment_version_code()
	
	gradle(
	  task: "clean bundleRelease",
	  project_dir: ENV["ANDROID_PROJECT_PATH"],
	  flags: "--no-daemon",
	  properties: {
	    "android.injected.signing.store.file" => File.join(Dir.pwd, "..", ENV["KEYSTORE_PATH"]),
	    "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
	    "android.injected.signing.key.alias" => ENV["KEYSTORE_ALIAS"],
	    "android.injected.signing.key.password" => ENV["KEYSTORE_PASSWORD"],
	  }
	)
	
	AAB_LOCATION = "#{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}"
	puts "AAB location: #{AAB_LOCATION}"
end

First, we are incrementing the build version code using increment_version_code. This is not a command provided by Fastlane, it’s a plugin that you need to add to your fastlane installation, and that’s as simple as running:

> fastlane add_plugin increment_version_code

This command will create a Pluginfile in your fastlane folder with the added plugin. You can commit this file as Fastlane will check and install plugins from this file at every run.

Then, the lane calls gradle with tasks clean and bundleRelease, and this time we also want to sign the build so we pass properties using our environment variables pointing to the keystore.

Then, run the command

> fastlane android bundle_production

And the resulting AAB file path is printed before the final fastlane summary table.

This is the signed production buld, so you can upload this file to the Play Store and publish it. We do this manually at the moment, but there’s a fastlane action to upload directly to the store: upload_to_play_store. You can find more informations in the docs https://docs.fastlane.tools/actions/upload_to_play_store/

This post doesn’t cover it now, but maybe it will in the future, tell us if you are interested 😃

Building remotely in GitHub CI

Good news: we have a GitHub action for Android too! Unfortunately it’s a lot simpler than our iOS action, because, again, we followed our needs, but you can contribute to it or open an issue and discuss it! Here’s the repo https://github.com/sparkfabrik/android-build-action

Using it in your action is just:

- uses: sparkfabrik/android-build-action@v1.0.0
  with:
    project-path: android
    output-path: my-app.apk
    browserstack-upload: true
    browserstack-username: ${{ secrets.BROWSERSTACK_USERNAME }}
    browserstack-access-key: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}

Again browserstack is optional here, the produced APK is unsigned and you can then download it as an Artifact.

Conclusions

Fastlane is a fantastic tool that lets you manage an entire build process without ever having to open an IDE, including signing and uploading to the app stores. Integrating all this to your local build is straightforward, and even in your CI/CD environment.

And there’s even more: for example there are actions in Fastlane to manage the git repo (like git_commit, add_git_tag or push_to_git_remote) or run tests, and many more, you can take full control over the build and release process of your app from a command-line, automate everything, share across the team and do real GitOps 😃

I hope this was a clear and exhaustive post, configuring Fastlane may seem confusing at the beginning but once you start to master some of its features you realize how much power you have at your fingers!

Uh, I almost forgot, here’s the complete Fastfile:

default_platform(:ios)

platform :ios do
  before_all do
    unlock_keychain(
      path: ENV['KEYCHAIN_NAME'],
      password: ENV['KEYCHAIN_PASSWORD']
    )

    if ENV["BUILD_PODS"] == 'true'
      cocoapods(
        podfile: ENV["PODS_PATH"],
        use_bundle_exec: false
      )
    end
  end

  desc "Export ipa"
  lane :export_ipa do
    use_workspace = !ENV['WORKSPACE_PATH'].empty?

    match(type: "development")

    build_app(
      workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
      project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
      configuration: ENV['CONFIGURATION'],
      scheme: ENV['SCHEME'],
      output_directory: File.dirname(ENV['OUTPUT_PATH']),
      output_name: File.basename(ENV['OUTPUT_PATH']),
      clean: true,
      export_method: ENV['EXPORT_METHOD'],
      export_team_id: ENV['TEAM_ID'],
    )
  end

  desc "Upload to TestFlight"
  lane :upload_testflight do
    use_workspace = !ENV['WORKSPACE_PATH'].empty?

    match(type: "appstore")

    increment_build_number(
      xcodeproj: ENV['IOS_PROJECT_PATH'],
      build_number: (latest_testflight_build_number(
        app_identifier: ENV["APP_ID"],
        username: ENV["APPSTORE_USERNAME"],
        team_name: ENV["TEAM_NAME"],
      ) + 1).to_s,
    )

    build_app(
      workspace: use_workspace ? ENV['WORKSPACE_PATH'] : nil,
      project: !use_workspace ? ENV['IOS_PROJECT_PATH'] : nil,
      configuration: "Release",
      scheme: ENV['SCHEME'],
      output_directory: File.dirname(ENV['OUTPUT_PATH']),
      output_name: File.basename(ENV['OUTPUT_PATH']),
      clean: true,
      include_bitcode: true,
      export_method: "app-store",
      export_team_id: ENV['TEAM_ID'],
    )

    upload_to_testflight(
      ipa: File.join(File.dirname(ENV['OUTPUT_PATH']), File.basename(ENV['OUTPUT_PATH'])),
      username: ENV['APPSTORE_USERNAME'],
      app_identifier: ENV['APP_ID'],
      team_name: ENV['TEAM_NAME'],
      skip_waiting_for_build_processing: true,
    )
  end

  desc "Release ipa"
  lane :release do
    deliver(
      app_identifier: ENV["APP_ID"],
      username: ENV["APPSTORE_USERNAME"],
      team_name: ENV["TEAM_NAME"],
      build_number: (latest_testflight_build_number(
        app_identifier: ENV["APP_ID"],
        username: ENV["APPSTORE_USERNAME"],
        team_name: ENV["TEAM_NAME"],
      )).to_s,
      precheck_include_in_app_purchases: false,
      submit_for_review: false,
      force: true, # Skip HTMl report verification
      skip_screenshots: true,
      skip_binary_upload: true,
      submit_for_review: true,
      # Comes from https://github.com/fastlane/fastlane/issues/5542#issuecomment-254201994
      submission_information: {
        add_id_info_serves_ads: false,
        add_id_info_tracks_action: false,
        add_id_info_tracks_install: false,
        add_id_info_uses_idfa: false,
        content_rights_has_rights: true,
        content_rights_contains_third_party_content: true,
        export_compliance_platform: 'ios',
        export_compliance_compliance_required: false,
        export_compliance_encryption_updated: false,
        export_compliance_app_type: nil,
        export_compliance_uses_encryption: false,
        export_compliance_is_exempt: false,
        export_compliance_contains_third_party_cryptography: false,
        export_compliance_contains_proprietary_cryptography: false,
        export_compliance_available_on_french_store: false
      },
    )
  end
end

platform :android do
  desc "Assemble release APK"
  lane :assemble do
    gradle(
      task: "clean assembleRelease",
      project_dir: ENV["ANDROID_PROJECT_PATH"],
      flags: "--no-daemon",
    )

    APK_LOCATION = "#{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]}"
    puts "APK location: #{APK_LOCATION}"
  end

  lane :bundle_production do
    increment_version_code()

    gradle(
      task: "clean bundleRelease",
      project_dir: ENV["ANDROID_PROJECT_PATH"],
      flags: "--no-daemon",
      properties: {
        "android.injected.signing.store.file" => File.join(Dir.pwd, "..", ENV["KEYSTORE_PATH"]),
        "android.injected.signing.store.password" => ENV["KEYSTORE_PASSWORD"],
        "android.injected.signing.key.alias" => ENV["KEYSTORE_ALIAS"],
        "android.injected.signing.key.password" => ENV["KEYSTORE_PASSWORD"],
      }
    )

    AAB_LOCATION = "#{lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]}"
    puts "AAB location: #{AAB_LOCATION}"
  end
end