Building a CI/CD Pipeline For A Cross-Platform React Native App

Introduction


Overview of the Project


In the ever-evolving world of mobile application development, the need for efficient and reliable automation processes has become paramount. As part of a standard development process - it’s paramount that any application built by a team of developers - has a deterministic build process that ensures consistency of the releases. This blog post delves into how our team automates the build process for React Native applications. However, the overall process scales to most apps built for iOS and Android. We take the example of React Native here since despite being a powerful tool to develop cross-platform applications, React Native presents unique challenges when automating deployments and releases. 



The project we’re about to delve into has two broad scopes: first, to automate the build process for Android apps using multiple flavors, and second, to do the same for iOS apps, utilizing multiple targets. This endeavor was not just about simplifying the build process; it was about setting up a robust pipeline that could handle multiple variants of an app, ensuring consistency, speed, and reliability in our development cycle.



Objectives and Challenges


The primary objective of this project was to streamline the build and deployment process for both Android and iOS platforms, enhancing the efficiency and reducing the manual effort involved. For the Android side, the challenge lay in configuring multiple flavors in React Native. Each flavor represented a different version of the app, with its own fonts, images and features. 



On the iOS front, the task was to set up multiple targets, a concept analogous to Android's flavors but with its own set of complexities. Each target needed to be configured to represent a different variant of the app, with different signing settings and build schemes. 



This complexity to build both iOS and Android app variants was amplified by the need to automate the app's deployment to the Google Play Store and Apple’s App Store using Fastlane, a tool that facilitates the supply of app metadata and binaries to the store - taking any manual interventions out of the equation.



For the build process, the idea was to use a self hosted Github Actions runner to build the Android flavors, and use CircleCI to build the various iOS targets. The only reason for a self hosted Github Actions runner is to save CI costs - and one could easily move all the builds to CircleCI as well. The integration was crucial, as it would enable us to automate the build and deployment process in a continuous integration environment. 



This setup posed its own set of challenges, particularly in managing provisioning profiles and certificates, which are critical in the iOS build process. Fastlane would again come to the rescue here, making it easier than ever to configure application signing for the project. 



Throughout this process, we faced various hurdles, from dealing with platform-specific nuances to ensuring the seamless integration of different tools. However, these challenges presented valuable learning opportunities and a chance to save developers’ time by automating tedious parts of React Native app deployments.


Setting the Stage: Understanding the Tools and Technologies


Brief Introduction to React Native


Developed by Facebook, React Native is an open-source framework that allows developers to build mobile apps using JavaScript and React. The beauty of React Native lies in its ability to compile native app components, enabling developers to write code once and deploy it on both Android and iOS platforms. This not only streamlines the development process but also maintains a comparable level of performance akin to native apps. React Native provides a rich ecosystem of libraries and tools, making it a preferred choice for developers looking to optimize development time without compromising on quality and performance.



Overview of Android Flavors and iOS Targets


When developing a React Native app for both Android and iOS, understanding the concept of 'flavors' and 'targets' is crucial. These concepts are pivotal in managing different versions of an app, each with its own set of configurations and features. These concepts are the same for any app built for Android and iOS - not just React Native.



Android Flavors


In Android development, 'flavors' refer to different versions of the app that can be generated using the same codebase. These flavors allow developers to change certain properties and resources for different app versions, such as the app's name, icons, and features. For instance, you might have a 'development' flavor for internal testing, which uses a different name and app icon compared to the 'production' flavor intended for end-users. Utilizing flavors makes it easier to manage multiple app versions, whether for different clients, testing purposes, or feature sets.



iOS Targets


In the iOS world, the concept similar to Android flavors is 'targets'. Each target in an iOS project can be thought of as a unique build of the app, complete with its own settings, resources, and provisioning profiles. Targets are particularly useful when you need to release different versions of the same app, possibly with slight variations in features or branding. For example, you might have a target for the free version of your app and another for the premium version. Unlike Android, where flavors are defined in the build.gradle configuration, iOS targets involve more in-depth setup within the Xcode project.


Automating Android Builds


Configuring Multiple Flavors in React Native for Android


The configuration process involves defining these flavors in the build.gradle file of the Android app. For example, this is the flavor definition for our devstage flavor in the productFlavors section:


create('devstage') {
            dimension = 'version'
            applicationIdSuffix = '.dev'
            applicationId 'com.todo.app'
            resValue 'string', 'app_name', 'My Todo App'
            versionName getNpmVersion()
            versionCode getNpmVersionCode()
            signingConfig signingConfigs.myConfig
}



This flavor will have a separate application ID, name and a signing configuration. Here, the dimension represents a set of related product flavors and the versionName and versionCode are defined in our package.json file and are fetched using the above listed functions.

Since this is a white-labeled app, multiple flavors also mean that multiple signing configurations are required. We can generate these using the following command:


keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000



This would require entering a keystore password, remember this as this will be needed in the signingConfig declaration in our build.gradle file, like so (Remember that the path in storeFile is relative to the android/app folder in your project directory):


myConfig {
            keyAlias 'alias_name'
            keyPassword 'your_password'
            storeFile file('my-release-key.keystore')
            storePassword 'your_password'
}



If you want different app icons, fonts or even manifests for your flavors, they can be specified in the android/app/src folder. React Native by default specifies two build types, debug and release. Let’s say you’re building a debug variant of your flavor. In our example, the flavor name is develop. We can specify either 



  1. A debug folder and a develop folder, with the flavor specific resources present in the develop folder, and a separate Flipper (debugger) configuration present in the debug folder, or

  2. A developdebug folder, with resources specific to the devstage debug variant.


Once the flavors are defined, the next step is to automate the build process for these variants. This involves setting up scripts that can handle the build and packaging of each flavor.



We can test our flavor by starting two terminals (Remember that the variant should be a combination of your flavor name and build type. Similarly, it’s important you specify the appId and the appIdSuffix, if you have defined them in your flavor definition): 



  1. In the first one, run npm start to start Metro. 

  2. In the other, run npx react-native run-android --variant=devstageDebug --appIdSuffix dev --appId com.todo.app to start building the devstage debug variant.



If you’re able to successfully run your application inside the emulator, you can now try to build the apk/aab with the following steps (Keep in mind that the first character of our build type and flavor name will have to be specified in uppercase.): 



  1. Make sure you’re in the android folder.

  2. For building an APK, run the command:
    ./gradlew "assembleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app

    The APK will be built at android/app/build/outputs/apk/develop/debug/developDebug.apk

  3. For building an AAB, run the command:
    ./gradlew "bundleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app.

    You can find the AAB at android/app/build/outputs/bundle/developDebug/app-develop-debug.aab




After the APK/AAB has been built, you can distribute it to your testers using the Firebase CLI. You should create an application in the App Distribution section of the Firebase Console and download the google-services.json file to your flavor’s folder present in android/app/src. Also note down the application ID. For this, you should:



  1. Generate a token you will use with the Firebase CLI in your CI environment using firebase login:ci.


  2. Then distribute your app using firebase appdistribution:distribute android/app/build/outputs/apk/develop/debug/developDebug.apk --app ${firebase_app_id} --token ${token_generated_from_step_1} --groups "iesoftek"


You can also automate the process to submit your app to review to Google Play Store using fastlane supply. For this, you would need to follow the setup instructions given at https://docs.fastlane.tools/getting-started/android/setup/ and then run the following command:


fastlane supply --aab android/app/build/outputs/bundle/developRelease/app-develop-release.aab --json-key develop.key.json --metadata_path android/app/src/develop/metadata



Since we have different flavors, we have stored the metadata by supplying the —metadata_path flag while running fastlane supply init. This way, we can deploy multiple flavors to the Play Store from our CI environment.



Here is a sample Github Action to build your Android application: 



name: build-app-dev
on:
  workflow_dispatch:
  push:
    branches:
      - develop
jobs:
  build-dev:
    runs-on:
      labels: self-hosted
    container:
      image: reactnativecommunity/react-native-android:8
    steps:
      - uses: actions/checkout@v2
      - name: Install npm dependencies and set env
        run: |
          n 18.17.1
          npm install
      - name: ⚙️ Build Android Release
        run: |
          ./gradlew "assembleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app
      - name: Distribute app
        run: |
          n 20.9.0
          npm install -g firebase-tools
          firebase appdistribution:distribute android/app/build/outputs/apk/develop/debug/developDebug.apk --app ${{ firebase_app_id }} --token ${{ secrets.FIREBASE_TOKEN }} --groups "iesoftek"


Automating iOS Builds




Setting up Multiple Targets in iOS


In the realm of iOS development, handling different app variants is achieved through the concept of 'targets'. Unlike Android's flavors, which are primarily configured in the build system, iOS targets are set up within Xcode and offer a more integrated approach with the iOS build environment. Each target represents a unique build configuration and can include its own set of assets, info.plist file, and even specific source files.



Differences from Android Flavors


The primary difference between iOS targets and Android flavors lies in their implementation and scope. While Android flavors are defined in the Gradle build scripts and mainly affect the build process, iOS targets are deeply integrated into the Xcode project structure, influencing both build and development processes. This integration allows for more granular control over each app variant, including unique bundle identifiers, app icons, and launch screens.



Configuration Steps


Configuring multiple targets in a React Native iOS project involves several key steps:



  1. Duplicate the existing target in Xcode for each new variant, renaming and adding resources to them according to your needs.

  2. Adjust settings like bundle identifier, build settings, and asset catalogs for each target.

  3. Ensure that dependencies are correctly linked and managed across different targets.



Using Fastlane with CircleCI for iOS Builds


Fastlane streamlines the iOS build and deployment process by automating tasks like screenshots, beta deployment, and release.



First, you should install fastlane using brew install fastlane. Then, we start by creating a new repository in your Github organization where your provisioning profiles and signing certificates will be stored. 



In our case, we have two “variants” of the app which are going to be built, let’s call them variantone and varianttwo. Create a new folder `fastlane` in the `ios` folder, and create two files called .env.variantone and .env.varianttwo. 



In each of these files, you should specify:


FASTLANE_TEAM_ID=your_team_id_from_apple_developer_account
MATCH_GIT_URL=https://github.com/your-github-organization/fastlane-config.git
MATCH_GIT_BRANCH=branch_name




Here, the MATCH_GIT_BRANCH should specify in which branch of the MATCH_GIT_URL repository the provisioning profiles for the two variants should be stored. 



After that, you should run the following command to create the provisioning profiles and the signing certificates. Make sure that you have access to the git repository specified above and you will be asked to create a password to decrypt these certificates and provisioning profiles. 



fastlane match adhoc --username ${your_apple_developer_account_email} --team_id ${your_team_id} --app_identifier ${bundle_app_id_} --env variantone



fastlane match appstore --username ${your_apple_developer_account_email} --team_id ${your_team_id} --app_identifier ${bundle_app_id} --env variantone



Here is the what you should specify in the flags:

  • username: Your Apple developer account email

  • team_id: Your team ID from your Apple Developer account.

  • app_identifier: The app identifier you created when creating a new target.

  • env: The env file for the variant you created earlier.



Notice that we are creating two types of provisioning profiles, adhoc and appstore. Adhoc provisioning profiles are mainly used for testing and distributing the app to a limited number of registered devices whose UDIDs (Unique Device Identifiers) are included in the profile. AppStore provisioning profiles, on the other hand, are used for distributing apps through the Apple App Store. These profiles do not limit the app to specific devices.



You should also make sure that the targets use the provisioning profiles generated by match. When you go to the build and signing section for your target, select the match provisioning profile. 



After this, you should create a new Fastfile in your fastlane folder and create a lane to build a particular variant. Here is a sample lane from our Fastfile for the develop target, similar to the devstage flavor we created in Android:



require 'json'
default_platform(:ios)
def get_version_and_build
  package_json = JSON.parse(File.read("../../package.json"))
  app_version = package_json['version']
  build_number = package_json['build'].to_s
  puts app_version
  puts build_number
  return [app_version, build_number]
end
platform :ios do
  desc "Build the iOS application"
  lane :develop do
    version, build = get_version_and_build
    update_app_identifier(
      xcodeproj: "Todoapp.xcodeproj",
      plist_path: "Todoapp/Info.plist",
      app_identifier: "com.todo.app.dev"
    )
    sync_code_signing(
      type: "adhoc",
      app_identifier: ['com.todo.app.dev'],
      force_for_new_devices: true
    )
    match(app_identifier: "com.todo.app.dev", type: "adhoc", verbose:true, force_for_new_devices: true, readonly: is_ci)
    build_app(
    scheme: "Todoapp DEV",
    export_method: "ad-hoc",
    export_options: {
    provisioningProfiles: { 
      "com.todo.app.dev" => "match AdHoc com.todo.app.dev"
      }
    })
  end
end






Here's a breakdown of what each part of this lane does:



  1. We fetch the version number and build number from package.json.

  2. update_app_identifier: This command changes the app's bundle identifier in the specified XCode project and plist file. We have added it here just to make sure that we’re building the correct target.

  3. sync_code_signing: This command is used to sync code signing assets using Fastlane’s match tool. The force_for_new_devices option ensures that new devices are added to the provisioning profile if necessary.

  4. build_app: This command builds the app. It specifies the scheme Todoapp DEV and the export method ad-hoc, meaning that this version is to be used for testing purposes only. The export_options include a mapping of the app identifier to its corresponding provisioning profile, ensuring that the build uses the correct profile generated by match.




In your ios folder, you can now try to run fastlane develop –env ${your_variant_name}. This should end up creating an .ipa file if everything goes successfully.



Now that we have the .ipa file, we would like to distribute it using Firebase. We can follow the same steps in the Android section by creating a new application ID in the App Distribution page on the Firebase Console, and then copying the resulting ID and then using the following command to first install the firebase fastlane plugin: fastlane add_plugin firebase_app_distribution



After that, you can add the following section to your Fastlane lane to automatically distribute the IPA to your testers. Make sure that you have added the GoogleService-Info.plist file to your target in the Xcode project (Run firebase login:ci to generate a token for use in CI environments):


firebase_app_distribution(
      app: "app_id_from_firebase",
      groups: "my_tester_group",
      firebase_cli_token: ENV["FIREBASE_TOKEN"],
)



You can also push your app to TestFlight from your lane itself. For this, you would need to generate an App Store Connect API key from this URL: https://appstoreconnect.apple.com/access/api. After getting this key, you should get the key ID, the issuer ID from the same page and then add the following sections to your lane: 




app_store_connect_api_key(
      key_id: "key_id_from_app_store_connect",
      issuer_id: "issuer_id_from_app_store_connect",
      key_content: ENV[APP_STORE_CONNECT_API_KEY_DATA'],
      duration: 1200, 
      in_house: false
)
upload_to_testflight(apple_id: "numeric_apple_app_id", team_id: "numeric_team_id", skip_waiting_for_build_processing: true)



Here, in the app_store_connect_api_key section, we’re defining the key we generated from App Store Connect. The key_content is the .p8 file you downloaded and ideally, its data should be stored in your CI environment. 



In the upload_to_testflight command, we upload the ipa to TestFlight. Note that the apple_id here is not your email address, but the ID of your app from App Store Connect. Similarly, the team_id is your App Store Connect team ID and not your Developer account team ID. You can fetch this ID by looking at the contentProviderId at this URL: https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/user/detail



Publishing to App Store


We will make use of fastlane’s “deliver” tool to push our built app to the App Store. For this, the expectation is that you already have an app present in your App Store Connect account. We will now run fastlane init to sync the data from App Store Connect to your repo. You can commit this data to your code repository, if necessary. 



fastlane deliver init
--username APPLE_DEVELOPER_ACCOUNT_EMAIL
--app_identifier APP_IDENTIFIER_FOR_YOUR_TARGET
--metadata_path /path/to/your/metadata
--team_id APPLE_DEVELOPER_TEAM_ID 
--screenshots_path /path/to/your/screenshots
--api_key_path /path/to/your/api/key
--force true 
--submit_for_review true



In this command, we’re specifying the metadata and screenshots paths because we would like to store the data for different variants in different places. 



After the lane has finished building your IPA, you can use the above fastlane deliver command without the `init` option and specify the IPA path using the --ipa flag to submit your app for review. 



You can build your iOS app on CircleCI using a MacOS runner with the following configuration: 



version: 2.1
orbs:
  node: circleci/node@5.1.0
commands:
  install_and_setup_dependencies:
    steps:
      - checkout
      - node/install:
          node-version: '18.17.1'
      - run:
          name: Install dependencies
          command: |
            npm install
            cd ios
            bundle install --path .bundle
            pod install
jobs:
  develop:
    macos:
      xcode: '15.1.0'
    steps:
      - checkout
      - install_and_setup_dependencies
      - run:
          name: Run devstage lane
          command: |
            cd ios
            fastlane develop --verbose --env variantone
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - devstage:
          filters:
            branches:
              only:
                - develop



Advanced Topics



Managing Environment-Specific Configurations



In any complex project, particularly one involving multiple builds across different environments, managing environment-specific configurations is a critical aspect. This involves setting up configurations that are specific to each build variant or target, such as API endpoints, feature flags, and third-party service keys.



For React Native projects, this often entails using different .env files or similar configuration mechanisms for each environment. Tools like react-native-config can be instrumental in managing these environment-specific variables. Here's how you can approach this:



  1. For Android:

    If you want to specify different environment variables for your flavors, you can make use of the react-native-config package and then specify the environment variables in the root of your build.gradle file: 


project.ext.envConfigFiles = [
    develop: '.env.staging',
    stage: '.env.stage,
    production: '.env.prod',
]


  1. For iOS:

    To specify different environment variables for different targets, you would need to create different .env files for each target, place them in your root directory and then edit the build scheme for your target. For your build scheme, under the Pre-actions scripts, you should specify that the env file for your target should be copied as .env in your root directory. For example, if you have setup an env file called .env.staging for your devstage variant, you would write the following Pre-action script [1]

    cp "${PROJECT_DIR}/../.env.staging" "${PROJECT_DIR}/../.env"



This approach helps in maintaining a clear separation between different environments and simplifies the process of switching contexts during development and deployment.





Automating Versioning and Release Notes



Automating versioning and release notes is another crucial aspect of a streamlined build process. Proper versioning helps in tracking releases and managing updates, while automated release notes provide clarity on the changes or features introduced with each release.



In this project, we will make use of changesets to keep track of our versions, automatically bump them up and create a CHANGELOG.md file in our root directory, which can then be used in our build processes to specify the release notes in our Firebase and fastlane commands. 



Changesets is a brilliant tool to generate changelogs and helps any project easily use the Semantic Versioning specification. To generate a changeset, you can run npx changeset in your root directory. You can then specify the version bump you would like, and then specify any changes that you have made during the sprint. After this process is complete, you will see some markdown files present in the .changeset folder, which would indicate that a version can be bumped automatically. 



Now, run npx changeset version and the version in your package.json file will be bumped up and a new CHANGELOG.md file will be created, which you can then commit to your code repository.



  1. In our project, there are three branches which signify three development environments:

    develop: As the name tells you, this is the development branch where all of the work happens during a sprint. 

  2. release/v*: These are the staging branches, which are created after the end of each sprint. The creation of this branch signifies that no new code will be merged into the develop branch.

  3. main: This is the production branch, to which the release branch is merged after the end of testing for the staging branch.



During a sprint, code gets checked into the develop branch, and after someone commits a changeset markdown file to this branch after the end of a sprint, a custom GitHub Action runs npx changeset and then commits and pushes the updated CHANGELOG.md and package.json files to a newly created release/v* branch, where v* signifies the version that has been updated in the package. json file.




Here is a sample GitHub action to better illustrate this workflow:


name: create-release-branch
on:
  push:
    branches:
      - develop
      - release/v*
    paths:
      - '.changeset/**.md'
jobs:
  create-release-branch:
    runs-on:
      labels: self-hosted
    container:
      image: ubuntu:22.04
    steps:
      - uses: actions/setup-node@v3
        with:
          node-version: 18.17.1
      - name: Install Dependencies
        run: |
          apt update && apt install git ca-certificates jq -y
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GH_TOKEN }}
      - name: Install GH CLI
        uses: dev-hanz-ops/install-gh-cli-action@v0.1.0
        with:
          gh-cli-version: 2.34.0
      - name: Create branch and close any previous PRs
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
        run: |
          npm install
          OLD_VERSION=$(jq -r '.version' package.json)
          npx changeset version
          VERSION=$(jq -r '.version' package.json)
          git config --global --add safe.directory "$GITHUB_WORKSPACE"
          git config --global user.email "a-ve@users.noreply.github.com"
          git config --global user.name "a-ve"
          git checkout -b release/v$VERSION
          git add package.json
          git add CHANGELOG.md
          git commit -m "Create branch for v$VERSION release"
          git push origin release/v$VERSION
          branch_name=release/v$OLD_VERSION
          prs=$(gh pr list --base develop --head $branch_name --json number --jq '.[].number')
          for pr in $prs; do
            gh pr close $pr 
            echo "Closed PR #$pr"
          done
          prs=$(gh pr list --base master --head $branch_name --json number --jq '.[].number')
          for pr in $prs; do
            gh pr close $pr 
            echo "Closed PR #$pr"
          done




By incorporating changesets into your development workflow, you can streamline the process of versioning and release note generation, making it easier to manage releases and keep stakeholders informed about the changes in each version.



Lessons Learned and Best Practices



Throughout this project, several key lessons were learned that are invaluable for anyone looking to automate the build process for React Native applications across different platforms.



  1. Breaking down the build process into smaller, manageable components (like flavors and targets) makes it easier to handle complexity. This approach not only simplifies troubleshooting but also enhances the clarity of the build process.

  2. Managing environment-specific configurations is crucial. Misconfigurations can lead to critical issues, especially when dealing with multiple build variants. Implementing a structured approach to configuration management is essential.

  3. Automating as much of the process as possible, from building and testing to deployment, not only saves time but also reduces the chances of human error. The use of tools like Fastlane, GitHub Actions and CircleCI was pivotal in achieving this.

  4. Implement continuous integration (CI) practices. Tools like GitHub Actions and CircleCI can automate the testing and building process, ensuring that every change is verified, and every build is consistent.




Tips for Scalability and Maintenance


Finally, to ensure that the build automation process remains scalable and maintainable over time, we have some suggestions:


  1. Good documentation of your build setup and configurations is crucial. This not only helps new team members get up to speed but also serves as a reference point for future modifications.

  2. As your project grows, the build process can become slower. Regularly review and optimize your scripts and configurations to ensure they are as efficient as possible.

  3. Design your automation setup with future growth in mind. Make it easy to add new flavors, targets, or integrate additional tools and services without having to overhaul the entire system.



Conclusion


Recap of Achievements


This project has been a significant journey in automating the build processes for React Native applications across Android and iOS platforms. We successfully configured multiple flavors and targets, facilitating the management of different app versions with unique characteristics. The integration of tools like Fastlane streamlined the entire build and deployment cycle, enhancing efficiency and reducing manual intervention. We overcame the complexities associated with environment-specific configurations and established a robust system for versioning and release management. This endeavor not only improved our immediate development workflow but also laid a foundation for a more scalable and maintainable app development lifecycle.



Future Directions and Potential Improvements


Looking ahead, there are several areas where this project can evolve and improve. Firstly, exploring further automation in testing, such as implementing more comprehensive automated UI tests, would enhance the quality assurance process. Additionally, adapting our build automation to incorporate newer technologies and tools as they emerge will keep our processes at the forefront of industry practices. Lastly, continuously revisiting and refining our processes will ensure that our build automation remains efficient, robust, and adaptable to the changing needs of the project and the evolving technology landscape.


Don't hesitate to reach out if you need help setting up your CI for any app!

References

[1] https://github.com/lugg/react-native-config?tab=readme-ov-file#ios-1

Introduction


Overview of the Project


In the ever-evolving world of mobile application development, the need for efficient and reliable automation processes has become paramount. As part of a standard development process - it’s paramount that any application built by a team of developers - has a deterministic build process that ensures consistency of the releases. This blog post delves into how our team automates the build process for React Native applications. However, the overall process scales to most apps built for iOS and Android. We take the example of React Native here since despite being a powerful tool to develop cross-platform applications, React Native presents unique challenges when automating deployments and releases. 



The project we’re about to delve into has two broad scopes: first, to automate the build process for Android apps using multiple flavors, and second, to do the same for iOS apps, utilizing multiple targets. This endeavor was not just about simplifying the build process; it was about setting up a robust pipeline that could handle multiple variants of an app, ensuring consistency, speed, and reliability in our development cycle.



Objectives and Challenges


The primary objective of this project was to streamline the build and deployment process for both Android and iOS platforms, enhancing the efficiency and reducing the manual effort involved. For the Android side, the challenge lay in configuring multiple flavors in React Native. Each flavor represented a different version of the app, with its own fonts, images and features. 



On the iOS front, the task was to set up multiple targets, a concept analogous to Android's flavors but with its own set of complexities. Each target needed to be configured to represent a different variant of the app, with different signing settings and build schemes. 



This complexity to build both iOS and Android app variants was amplified by the need to automate the app's deployment to the Google Play Store and Apple’s App Store using Fastlane, a tool that facilitates the supply of app metadata and binaries to the store - taking any manual interventions out of the equation.



For the build process, the idea was to use a self hosted Github Actions runner to build the Android flavors, and use CircleCI to build the various iOS targets. The only reason for a self hosted Github Actions runner is to save CI costs - and one could easily move all the builds to CircleCI as well. The integration was crucial, as it would enable us to automate the build and deployment process in a continuous integration environment. 



This setup posed its own set of challenges, particularly in managing provisioning profiles and certificates, which are critical in the iOS build process. Fastlane would again come to the rescue here, making it easier than ever to configure application signing for the project. 



Throughout this process, we faced various hurdles, from dealing with platform-specific nuances to ensuring the seamless integration of different tools. However, these challenges presented valuable learning opportunities and a chance to save developers’ time by automating tedious parts of React Native app deployments.


Setting the Stage: Understanding the Tools and Technologies


Brief Introduction to React Native


Developed by Facebook, React Native is an open-source framework that allows developers to build mobile apps using JavaScript and React. The beauty of React Native lies in its ability to compile native app components, enabling developers to write code once and deploy it on both Android and iOS platforms. This not only streamlines the development process but also maintains a comparable level of performance akin to native apps. React Native provides a rich ecosystem of libraries and tools, making it a preferred choice for developers looking to optimize development time without compromising on quality and performance.



Overview of Android Flavors and iOS Targets


When developing a React Native app for both Android and iOS, understanding the concept of 'flavors' and 'targets' is crucial. These concepts are pivotal in managing different versions of an app, each with its own set of configurations and features. These concepts are the same for any app built for Android and iOS - not just React Native.



Android Flavors


In Android development, 'flavors' refer to different versions of the app that can be generated using the same codebase. These flavors allow developers to change certain properties and resources for different app versions, such as the app's name, icons, and features. For instance, you might have a 'development' flavor for internal testing, which uses a different name and app icon compared to the 'production' flavor intended for end-users. Utilizing flavors makes it easier to manage multiple app versions, whether for different clients, testing purposes, or feature sets.



iOS Targets


In the iOS world, the concept similar to Android flavors is 'targets'. Each target in an iOS project can be thought of as a unique build of the app, complete with its own settings, resources, and provisioning profiles. Targets are particularly useful when you need to release different versions of the same app, possibly with slight variations in features or branding. For example, you might have a target for the free version of your app and another for the premium version. Unlike Android, where flavors are defined in the build.gradle configuration, iOS targets involve more in-depth setup within the Xcode project.


Automating Android Builds


Configuring Multiple Flavors in React Native for Android


The configuration process involves defining these flavors in the build.gradle file of the Android app. For example, this is the flavor definition for our devstage flavor in the productFlavors section:


create('devstage') {
            dimension = 'version'
            applicationIdSuffix = '.dev'
            applicationId 'com.todo.app'
            resValue 'string', 'app_name', 'My Todo App'
            versionName getNpmVersion()
            versionCode getNpmVersionCode()
            signingConfig signingConfigs.myConfig
}



This flavor will have a separate application ID, name and a signing configuration. Here, the dimension represents a set of related product flavors and the versionName and versionCode are defined in our package.json file and are fetched using the above listed functions.

Since this is a white-labeled app, multiple flavors also mean that multiple signing configurations are required. We can generate these using the following command:


keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000



This would require entering a keystore password, remember this as this will be needed in the signingConfig declaration in our build.gradle file, like so (Remember that the path in storeFile is relative to the android/app folder in your project directory):


myConfig {
            keyAlias 'alias_name'
            keyPassword 'your_password'
            storeFile file('my-release-key.keystore')
            storePassword 'your_password'
}



If you want different app icons, fonts or even manifests for your flavors, they can be specified in the android/app/src folder. React Native by default specifies two build types, debug and release. Let’s say you’re building a debug variant of your flavor. In our example, the flavor name is develop. We can specify either 



  1. A debug folder and a develop folder, with the flavor specific resources present in the develop folder, and a separate Flipper (debugger) configuration present in the debug folder, or

  2. A developdebug folder, with resources specific to the devstage debug variant.


Once the flavors are defined, the next step is to automate the build process for these variants. This involves setting up scripts that can handle the build and packaging of each flavor.



We can test our flavor by starting two terminals (Remember that the variant should be a combination of your flavor name and build type. Similarly, it’s important you specify the appId and the appIdSuffix, if you have defined them in your flavor definition): 



  1. In the first one, run npm start to start Metro. 

  2. In the other, run npx react-native run-android --variant=devstageDebug --appIdSuffix dev --appId com.todo.app to start building the devstage debug variant.



If you’re able to successfully run your application inside the emulator, you can now try to build the apk/aab with the following steps (Keep in mind that the first character of our build type and flavor name will have to be specified in uppercase.): 



  1. Make sure you’re in the android folder.

  2. For building an APK, run the command:
    ./gradlew "assembleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app

    The APK will be built at android/app/build/outputs/apk/develop/debug/developDebug.apk

  3. For building an AAB, run the command:
    ./gradlew "bundleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app.

    You can find the AAB at android/app/build/outputs/bundle/developDebug/app-develop-debug.aab




After the APK/AAB has been built, you can distribute it to your testers using the Firebase CLI. You should create an application in the App Distribution section of the Firebase Console and download the google-services.json file to your flavor’s folder present in android/app/src. Also note down the application ID. For this, you should:



  1. Generate a token you will use with the Firebase CLI in your CI environment using firebase login:ci.


  2. Then distribute your app using firebase appdistribution:distribute android/app/build/outputs/apk/develop/debug/developDebug.apk --app ${firebase_app_id} --token ${token_generated_from_step_1} --groups "iesoftek"


You can also automate the process to submit your app to review to Google Play Store using fastlane supply. For this, you would need to follow the setup instructions given at https://docs.fastlane.tools/getting-started/android/setup/ and then run the following command:


fastlane supply --aab android/app/build/outputs/bundle/developRelease/app-develop-release.aab --json-key develop.key.json --metadata_path android/app/src/develop/metadata



Since we have different flavors, we have stored the metadata by supplying the —metadata_path flag while running fastlane supply init. This way, we can deploy multiple flavors to the Play Store from our CI environment.



Here is a sample Github Action to build your Android application: 



name: build-app-dev
on:
  workflow_dispatch:
  push:
    branches:
      - develop
jobs:
  build-dev:
    runs-on:
      labels: self-hosted
    container:
      image: reactnativecommunity/react-native-android:8
    steps:
      - uses: actions/checkout@v2
      - name: Install npm dependencies and set env
        run: |
          n 18.17.1
          npm install
      - name: ⚙️ Build Android Release
        run: |
          ./gradlew "assembleDevelopDebugRelease" -PapplicationIdSuffix=dev -PapplicationId=com.todo.app
      - name: Distribute app
        run: |
          n 20.9.0
          npm install -g firebase-tools
          firebase appdistribution:distribute android/app/build/outputs/apk/develop/debug/developDebug.apk --app ${{ firebase_app_id }} --token ${{ secrets.FIREBASE_TOKEN }} --groups "iesoftek"


Automating iOS Builds




Setting up Multiple Targets in iOS


In the realm of iOS development, handling different app variants is achieved through the concept of 'targets'. Unlike Android's flavors, which are primarily configured in the build system, iOS targets are set up within Xcode and offer a more integrated approach with the iOS build environment. Each target represents a unique build configuration and can include its own set of assets, info.plist file, and even specific source files.



Differences from Android Flavors


The primary difference between iOS targets and Android flavors lies in their implementation and scope. While Android flavors are defined in the Gradle build scripts and mainly affect the build process, iOS targets are deeply integrated into the Xcode project structure, influencing both build and development processes. This integration allows for more granular control over each app variant, including unique bundle identifiers, app icons, and launch screens.



Configuration Steps


Configuring multiple targets in a React Native iOS project involves several key steps:



  1. Duplicate the existing target in Xcode for each new variant, renaming and adding resources to them according to your needs.

  2. Adjust settings like bundle identifier, build settings, and asset catalogs for each target.

  3. Ensure that dependencies are correctly linked and managed across different targets.



Using Fastlane with CircleCI for iOS Builds


Fastlane streamlines the iOS build and deployment process by automating tasks like screenshots, beta deployment, and release.



First, you should install fastlane using brew install fastlane. Then, we start by creating a new repository in your Github organization where your provisioning profiles and signing certificates will be stored. 



In our case, we have two “variants” of the app which are going to be built, let’s call them variantone and varianttwo. Create a new folder `fastlane` in the `ios` folder, and create two files called .env.variantone and .env.varianttwo. 



In each of these files, you should specify:


FASTLANE_TEAM_ID=your_team_id_from_apple_developer_account
MATCH_GIT_URL=https://github.com/your-github-organization/fastlane-config.git
MATCH_GIT_BRANCH=branch_name




Here, the MATCH_GIT_BRANCH should specify in which branch of the MATCH_GIT_URL repository the provisioning profiles for the two variants should be stored. 



After that, you should run the following command to create the provisioning profiles and the signing certificates. Make sure that you have access to the git repository specified above and you will be asked to create a password to decrypt these certificates and provisioning profiles. 



fastlane match adhoc --username ${your_apple_developer_account_email} --team_id ${your_team_id} --app_identifier ${bundle_app_id_} --env variantone



fastlane match appstore --username ${your_apple_developer_account_email} --team_id ${your_team_id} --app_identifier ${bundle_app_id} --env variantone



Here is the what you should specify in the flags:

  • username: Your Apple developer account email

  • team_id: Your team ID from your Apple Developer account.

  • app_identifier: The app identifier you created when creating a new target.

  • env: The env file for the variant you created earlier.



Notice that we are creating two types of provisioning profiles, adhoc and appstore. Adhoc provisioning profiles are mainly used for testing and distributing the app to a limited number of registered devices whose UDIDs (Unique Device Identifiers) are included in the profile. AppStore provisioning profiles, on the other hand, are used for distributing apps through the Apple App Store. These profiles do not limit the app to specific devices.



You should also make sure that the targets use the provisioning profiles generated by match. When you go to the build and signing section for your target, select the match provisioning profile. 



After this, you should create a new Fastfile in your fastlane folder and create a lane to build a particular variant. Here is a sample lane from our Fastfile for the develop target, similar to the devstage flavor we created in Android:



require 'json'
default_platform(:ios)
def get_version_and_build
  package_json = JSON.parse(File.read("../../package.json"))
  app_version = package_json['version']
  build_number = package_json['build'].to_s
  puts app_version
  puts build_number
  return [app_version, build_number]
end
platform :ios do
  desc "Build the iOS application"
  lane :develop do
    version, build = get_version_and_build
    update_app_identifier(
      xcodeproj: "Todoapp.xcodeproj",
      plist_path: "Todoapp/Info.plist",
      app_identifier: "com.todo.app.dev"
    )
    sync_code_signing(
      type: "adhoc",
      app_identifier: ['com.todo.app.dev'],
      force_for_new_devices: true
    )
    match(app_identifier: "com.todo.app.dev", type: "adhoc", verbose:true, force_for_new_devices: true, readonly: is_ci)
    build_app(
    scheme: "Todoapp DEV",
    export_method: "ad-hoc",
    export_options: {
    provisioningProfiles: { 
      "com.todo.app.dev" => "match AdHoc com.todo.app.dev"
      }
    })
  end
end






Here's a breakdown of what each part of this lane does:



  1. We fetch the version number and build number from package.json.

  2. update_app_identifier: This command changes the app's bundle identifier in the specified XCode project and plist file. We have added it here just to make sure that we’re building the correct target.

  3. sync_code_signing: This command is used to sync code signing assets using Fastlane’s match tool. The force_for_new_devices option ensures that new devices are added to the provisioning profile if necessary.

  4. build_app: This command builds the app. It specifies the scheme Todoapp DEV and the export method ad-hoc, meaning that this version is to be used for testing purposes only. The export_options include a mapping of the app identifier to its corresponding provisioning profile, ensuring that the build uses the correct profile generated by match.




In your ios folder, you can now try to run fastlane develop –env ${your_variant_name}. This should end up creating an .ipa file if everything goes successfully.



Now that we have the .ipa file, we would like to distribute it using Firebase. We can follow the same steps in the Android section by creating a new application ID in the App Distribution page on the Firebase Console, and then copying the resulting ID and then using the following command to first install the firebase fastlane plugin: fastlane add_plugin firebase_app_distribution



After that, you can add the following section to your Fastlane lane to automatically distribute the IPA to your testers. Make sure that you have added the GoogleService-Info.plist file to your target in the Xcode project (Run firebase login:ci to generate a token for use in CI environments):


firebase_app_distribution(
      app: "app_id_from_firebase",
      groups: "my_tester_group",
      firebase_cli_token: ENV["FIREBASE_TOKEN"],
)



You can also push your app to TestFlight from your lane itself. For this, you would need to generate an App Store Connect API key from this URL: https://appstoreconnect.apple.com/access/api. After getting this key, you should get the key ID, the issuer ID from the same page and then add the following sections to your lane: 




app_store_connect_api_key(
      key_id: "key_id_from_app_store_connect",
      issuer_id: "issuer_id_from_app_store_connect",
      key_content: ENV[APP_STORE_CONNECT_API_KEY_DATA'],
      duration: 1200, 
      in_house: false
)
upload_to_testflight(apple_id: "numeric_apple_app_id", team_id: "numeric_team_id", skip_waiting_for_build_processing: true)



Here, in the app_store_connect_api_key section, we’re defining the key we generated from App Store Connect. The key_content is the .p8 file you downloaded and ideally, its data should be stored in your CI environment. 



In the upload_to_testflight command, we upload the ipa to TestFlight. Note that the apple_id here is not your email address, but the ID of your app from App Store Connect. Similarly, the team_id is your App Store Connect team ID and not your Developer account team ID. You can fetch this ID by looking at the contentProviderId at this URL: https://appstoreconnect.apple.com/WebObjects/iTunesConnect.woa/ra/user/detail



Publishing to App Store


We will make use of fastlane’s “deliver” tool to push our built app to the App Store. For this, the expectation is that you already have an app present in your App Store Connect account. We will now run fastlane init to sync the data from App Store Connect to your repo. You can commit this data to your code repository, if necessary. 



fastlane deliver init
--username APPLE_DEVELOPER_ACCOUNT_EMAIL
--app_identifier APP_IDENTIFIER_FOR_YOUR_TARGET
--metadata_path /path/to/your/metadata
--team_id APPLE_DEVELOPER_TEAM_ID 
--screenshots_path /path/to/your/screenshots
--api_key_path /path/to/your/api/key
--force true 
--submit_for_review true



In this command, we’re specifying the metadata and screenshots paths because we would like to store the data for different variants in different places. 



After the lane has finished building your IPA, you can use the above fastlane deliver command without the `init` option and specify the IPA path using the --ipa flag to submit your app for review. 



You can build your iOS app on CircleCI using a MacOS runner with the following configuration: 



version: 2.1
orbs:
  node: circleci/node@5.1.0
commands:
  install_and_setup_dependencies:
    steps:
      - checkout
      - node/install:
          node-version: '18.17.1'
      - run:
          name: Install dependencies
          command: |
            npm install
            cd ios
            bundle install --path .bundle
            pod install
jobs:
  develop:
    macos:
      xcode: '15.1.0'
    steps:
      - checkout
      - install_and_setup_dependencies
      - run:
          name: Run devstage lane
          command: |
            cd ios
            fastlane develop --verbose --env variantone
workflows:
  version: 2
  build-and-deploy:
    jobs:
      - devstage:
          filters:
            branches:
              only:
                - develop



Advanced Topics



Managing Environment-Specific Configurations



In any complex project, particularly one involving multiple builds across different environments, managing environment-specific configurations is a critical aspect. This involves setting up configurations that are specific to each build variant or target, such as API endpoints, feature flags, and third-party service keys.



For React Native projects, this often entails using different .env files or similar configuration mechanisms for each environment. Tools like react-native-config can be instrumental in managing these environment-specific variables. Here's how you can approach this:



  1. For Android:

    If you want to specify different environment variables for your flavors, you can make use of the react-native-config package and then specify the environment variables in the root of your build.gradle file: 


project.ext.envConfigFiles = [
    develop: '.env.staging',
    stage: '.env.stage,
    production: '.env.prod',
]


  1. For iOS:

    To specify different environment variables for different targets, you would need to create different .env files for each target, place them in your root directory and then edit the build scheme for your target. For your build scheme, under the Pre-actions scripts, you should specify that the env file for your target should be copied as .env in your root directory. For example, if you have setup an env file called .env.staging for your devstage variant, you would write the following Pre-action script [1]

    cp "${PROJECT_DIR}/../.env.staging" "${PROJECT_DIR}/../.env"



This approach helps in maintaining a clear separation between different environments and simplifies the process of switching contexts during development and deployment.





Automating Versioning and Release Notes



Automating versioning and release notes is another crucial aspect of a streamlined build process. Proper versioning helps in tracking releases and managing updates, while automated release notes provide clarity on the changes or features introduced with each release.



In this project, we will make use of changesets to keep track of our versions, automatically bump them up and create a CHANGELOG.md file in our root directory, which can then be used in our build processes to specify the release notes in our Firebase and fastlane commands. 



Changesets is a brilliant tool to generate changelogs and helps any project easily use the Semantic Versioning specification. To generate a changeset, you can run npx changeset in your root directory. You can then specify the version bump you would like, and then specify any changes that you have made during the sprint. After this process is complete, you will see some markdown files present in the .changeset folder, which would indicate that a version can be bumped automatically. 



Now, run npx changeset version and the version in your package.json file will be bumped up and a new CHANGELOG.md file will be created, which you can then commit to your code repository.



  1. In our project, there are three branches which signify three development environments:

    develop: As the name tells you, this is the development branch where all of the work happens during a sprint. 

  2. release/v*: These are the staging branches, which are created after the end of each sprint. The creation of this branch signifies that no new code will be merged into the develop branch.

  3. main: This is the production branch, to which the release branch is merged after the end of testing for the staging branch.



During a sprint, code gets checked into the develop branch, and after someone commits a changeset markdown file to this branch after the end of a sprint, a custom GitHub Action runs npx changeset and then commits and pushes the updated CHANGELOG.md and package.json files to a newly created release/v* branch, where v* signifies the version that has been updated in the package. json file.




Here is a sample GitHub action to better illustrate this workflow:


name: create-release-branch
on:
  push:
    branches:
      - develop
      - release/v*
    paths:
      - '.changeset/**.md'
jobs:
  create-release-branch:
    runs-on:
      labels: self-hosted
    container:
      image: ubuntu:22.04
    steps:
      - uses: actions/setup-node@v3
        with:
          node-version: 18.17.1
      - name: Install Dependencies
        run: |
          apt update && apt install git ca-certificates jq -y
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
          token: ${{ secrets.GH_TOKEN }}
      - name: Install GH CLI
        uses: dev-hanz-ops/install-gh-cli-action@v0.1.0
        with:
          gh-cli-version: 2.34.0
      - name: Create branch and close any previous PRs
        env:
          GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
        run: |
          npm install
          OLD_VERSION=$(jq -r '.version' package.json)
          npx changeset version
          VERSION=$(jq -r '.version' package.json)
          git config --global --add safe.directory "$GITHUB_WORKSPACE"
          git config --global user.email "a-ve@users.noreply.github.com"
          git config --global user.name "a-ve"
          git checkout -b release/v$VERSION
          git add package.json
          git add CHANGELOG.md
          git commit -m "Create branch for v$VERSION release"
          git push origin release/v$VERSION
          branch_name=release/v$OLD_VERSION
          prs=$(gh pr list --base develop --head $branch_name --json number --jq '.[].number')
          for pr in $prs; do
            gh pr close $pr 
            echo "Closed PR #$pr"
          done
          prs=$(gh pr list --base master --head $branch_name --json number --jq '.[].number')
          for pr in $prs; do
            gh pr close $pr 
            echo "Closed PR #$pr"
          done




By incorporating changesets into your development workflow, you can streamline the process of versioning and release note generation, making it easier to manage releases and keep stakeholders informed about the changes in each version.



Lessons Learned and Best Practices



Throughout this project, several key lessons were learned that are invaluable for anyone looking to automate the build process for React Native applications across different platforms.



  1. Breaking down the build process into smaller, manageable components (like flavors and targets) makes it easier to handle complexity. This approach not only simplifies troubleshooting but also enhances the clarity of the build process.

  2. Managing environment-specific configurations is crucial. Misconfigurations can lead to critical issues, especially when dealing with multiple build variants. Implementing a structured approach to configuration management is essential.

  3. Automating as much of the process as possible, from building and testing to deployment, not only saves time but also reduces the chances of human error. The use of tools like Fastlane, GitHub Actions and CircleCI was pivotal in achieving this.

  4. Implement continuous integration (CI) practices. Tools like GitHub Actions and CircleCI can automate the testing and building process, ensuring that every change is verified, and every build is consistent.




Tips for Scalability and Maintenance


Finally, to ensure that the build automation process remains scalable and maintainable over time, we have some suggestions:


  1. Good documentation of your build setup and configurations is crucial. This not only helps new team members get up to speed but also serves as a reference point for future modifications.

  2. As your project grows, the build process can become slower. Regularly review and optimize your scripts and configurations to ensure they are as efficient as possible.

  3. Design your automation setup with future growth in mind. Make it easy to add new flavors, targets, or integrate additional tools and services without having to overhaul the entire system.



Conclusion


Recap of Achievements


This project has been a significant journey in automating the build processes for React Native applications across Android and iOS platforms. We successfully configured multiple flavors and targets, facilitating the management of different app versions with unique characteristics. The integration of tools like Fastlane streamlined the entire build and deployment cycle, enhancing efficiency and reducing manual intervention. We overcame the complexities associated with environment-specific configurations and established a robust system for versioning and release management. This endeavor not only improved our immediate development workflow but also laid a foundation for a more scalable and maintainable app development lifecycle.



Future Directions and Potential Improvements


Looking ahead, there are several areas where this project can evolve and improve. Firstly, exploring further automation in testing, such as implementing more comprehensive automated UI tests, would enhance the quality assurance process. Additionally, adapting our build automation to incorporate newer technologies and tools as they emerge will keep our processes at the forefront of industry practices. Lastly, continuously revisiting and refining our processes will ensure that our build automation remains efficient, robust, and adaptable to the changing needs of the project and the evolving technology landscape.


Don't hesitate to reach out if you need help setting up your CI for any app!

References

[1] https://github.com/lugg/react-native-config?tab=readme-ov-file#ios-1

Want to build something great?

Let's build something extraordinary together

Request a free consultation

Want to build something great?

Let's build something extraordinary together

Request a free consultation