DEV Community

Cover image for From days to minutes: Build and publish React Native apps using Fastlane and Github Actions
Mohamed Saad
Mohamed Saad

Posted on

From days to minutes: Build and publish React Native apps using Fastlane and Github Actions

In the fast-paced world of mobile app development, delivering updates and new features quickly is critical. For teams managing multiple white-label applications, the challenge intensifies. This article delves into how I automated the build and release process for 20+ white-label React Native apps across Android and iOS using GitHub Actions and Fastlane, reducing release time from over three working days to just 20–30 minutes.

The Problem

Our team faced several challenges that requires us to build this solution:

  1. High Manual Overhead:
    Managing over 20 white-label apps with the same codebase but unique app identifiers meant manually building and releasing separate versions for Android and iOS. This process was time-consuming and error-prone.

  2. Platform-Specific Complexity:

    • Android: Required keystore management and distinct build formats (APK vs AAB).
    • iOS: Involved handling provisioning profiles, certificates, and Apple’s two-factor authentication (2FA).
  3. A Single Point of Failure:
    One person was responsible for the entire process of building and releasing the apps, causing delays and introducing a significant bottleneck.

  4. Budget Constraints:
    Third-party CI/CD solutions like CodeMagic, Bitrise, and Appcircle were unaffordable, forcing us to explore open-source and free alternatives.

  5. Impossible to onboard new team members:
    The process was complex and required a deep understanding of the build and release process, making it difficult to onboard new team members.

Comparing different CI/CD solutions and Trade-offs

We evaluated several CI/CD solutions, including GitHub Actions, Bitrise, Appcircle and other solutions. Here’s a comparison of the tools based on our requirements:

  1. GitHub Actions:

    • Pros:
      • Free for public repositories.
      • Integrates seamlessly with GitHub.
      • Supports custom workflows.
      • Offers a wide range of actions.
    • Cons:
      • Limited build minutes for private repositories.
      • Requires manual setup and configuration.
      • 1 minute in mac OS runner equals 10 minutes in bill payment.
  2. Bitrise:

    • Pros:
      • Easy to set up and configure.
      • Offers a wide range of integrations.
      • Provides a user-friendly interface.
    • Cons:
      • Expensive for multiple apps.
      • Limited build minutes.
  3. Appcircle:

    • Pros:
      • Supports multiple platforms.
      • Offers a wide range of integrations.
      • Provides a user-friendly interface.
    • Cons:
      • Expensive for multiple apps.
      • Limited build minutes.
  4. Codemagic:

    • Pros:
      • Easy to set up and configure.
      • Offers a wide range of integrations.
      • Provides a user-friendly interface.
    • Cons:
      • Expensive for multiple apps.
      • Limited build minutes.
  5. Gitlab CI:

    • Pros:
      • Free for public repositories.
      • Integrates seamlessly with Gitlab.
      • Supports custom workflows.
    • Cons:
      • Limited build minutes for private repositories.
      • Requires manual setup and configuration.
      • Mac OS runner is not stable.

After evaluating the options, we decided to go with GitHub Actions due to its seamless integration with GitHub, custom workflows, and cost-effectiveness.

Github actions

GitHub Actions is a powerful tool for automating workflows directly within your repository. It allows you to define workflows that are triggered by events, such as pushing code, opening pull requests, or even on-demand through manual dispatch. Here’s a breakdown of the essential concepts:


1. Syntax & Skeleton

GitHub Actions workflows are defined in YAML files located in the .github/workflows/ directory. The general structure of a workflow file includes:

  • name: The workflow's name.
  • on: The event that triggers the workflow. e.g. push, workflow_dispatch, ...etc
  • jobs: A collection of jobs to be executed.

Example Skeleton:

name: Build & Test

on:
  push:
    branches:
      - main
  pull_request:
    branches:
      - main
  workflow_dispatch:

jobs:
  example-job:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
Enter fullscreen mode Exit fullscreen mode

2. Jobs:

Jobs define units of work in a workflow. They run either:

  • In parallel: By default, jobs are independent and run simultaneously.
  • Chained: Using the needs keyword to specify dependencies.

Example of Parallel Jobs:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Build project
        run: echo "Building project"

  test:
    runs-on: ubuntu-latest
    steps:
      - name: Run tests
        run: echo "Running tests"
Enter fullscreen mode Exit fullscreen mode

Example of Chained Jobs:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Build project
        run: echo "Building project"

  deploy:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Deploy project
        run: echo "Deploying project"
Enter fullscreen mode Exit fullscreen mode

3. Steps

Steps define the individual tasks within a job. They can include:

  • run commands: To execute shell scripts.
  • Reusable actions: Third-party or custom actions from the GitHub Marketplace.

Example Steps:

steps:
  - name: Checkout repository
    uses: actions/checkout@v2

  - name: Install dependencies
    run: npm install

  - name: Run tests
    run: npm test
Enter fullscreen mode Exit fullscreen mode

4. Reusable Actions

Reusable actions simplify workflows by allowing you to use pre-built logic. These are either official GitHub Actions or custom ones stored in a repository.

Example of a Reusable Action:

- name: Checkout repository
  uses: actions/checkout@v1
Enter fullscreen mode Exit fullscreen mode

You can also create custom actions for your specific needs and share them across workflows or repositories.


5. Conditions

Conditions control when specific jobs or steps should run. This is particularly useful for implementing logic based on branch names, event types, or custom environment variables.

Example Using Conditions:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Run only on main branch
        run: echo "This runs on main"
        if: github.ref == 'refs/heads/main'
Enter fullscreen mode Exit fullscreen mode

You can use conditions for:

  • Branch-specific actions.
  • Running based on the success/failure of previous steps or jobs.

6. Secrets

Secrets are encrypted environment variables stored securely in your GitHub repository or organization. They are used to protect sensitive data like API keys, credentials, and tokens.

Example of Using Secrets:

- name: Use secret key
  run: echo "Your secret is ${{ secrets.SECRET_KEY }}"
Enter fullscreen mode Exit fullscreen mode

To add secrets:

  1. Navigate to your repository settings.
  2. Go to Secrets and variables > Actions.
  3. Add your secret and reference it in the workflow.

By combining these elements, you can create sophisticated workflows tailored to your CI/CD needs. GitHub Actions’ flexibility allows for robust automation, enhancing productivity and reducing manual effort.

Steps to build each platform manually

Before automating the build, we need to understand first how we can build for every platform manually to know what is the background for every decision needed.

Build for Android

Building an Android app manually requires setting up the development environment and understanding the key steps in the process. Here's a breakdown of what's needed and how to do it:


1. Install Java

Java is a fundamental requirement for building Android apps. Android builds rely on the Java Development Kit (JDK).

  • Ensure Java is installed: Version 11 or higher is commonly used.
  • Verify Installation: Run the command: java -version This should display the installed Java version.

2. Install Gradle

Gradle is the build system used by Android to automate the compilation, testing, and packaging process.

  • Install Gradle: Download it from the official Gradle website.
  • Verify Installation: Run the command: gradle -v This should display the installed Gradle version.

Tip: Gradle is typically managed automatically by Android Studio, but for manual builds, you'll need it installed on your system.


3. Obtain a Keystore File

The keystore file is a critical component for signing your Android app before release. It ensures that your app is verified and allows updates to be trusted by the Play Store.

  • Create a Keystore File Locally: Use the keytool command to generate a keystore:
  keytool -genkey -v -keystore your-app.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias your-app
Enter fullscreen mode Exit fullscreen mode

This will prompt you to enter a password and other details.

  • Problem in CI/CD Pipelines: In CI/CD workflows, storing the keystore securely can be a challenge. Exposing the keystore file directly in the repository is a security risk. We will come back to this challenge and see how to solve it.

4. Understanding APK vs. AAB

When building Android apps, you can output two types of artifacts: APK and AAB.

  • APK (Android Package):

    • The traditional format for Android apps.
    • Contains all the code, resources, and assets required to run the app.
    • Suitable for direct installation or distribution outside the Play Store.
  • AAB (Android App Bundle):

    • A modern packaging format introduced by Google.
    • Optimized for Play Store distribution.
    • The Play Store dynamically generates an APK tailored to the user’s device from the AAB.
    • Results in smaller app downloads and improved performance.

Key Differences:

Feature APK AAB
Size Larger Smaller (device-specific)
Distribution Direct or via Play Store Play Store only
Customization Single package for all devices Device-specific APKs on demand

For Play Store submissions, Google now mandates using the AAB format. However, APKs are still useful for testing and distributing apps directly to users.


These steps and understanding the differences between APK and AAB will help building Android apps manually while ensuring they are properly signed and ready for distribution.

Build for iOS

Building an iOS app requires specific tools, configurations, and processes to ensure compatibility with Apple’s ecosystem. Here’re the steps to build and iOS version manually:


1. Install and Manage CocoaPods

CocoaPods is a dependency manager for iOS projects. It helps manage libraries and frameworks needed for your app.

  • Install CocoaPods: Use the following command to install CocoaPods if it’s not already installed:
  sudo gem install cocoapods
Enter fullscreen mode Exit fullscreen mode
  • Install Dependencies: Navigate to your iOS project directory and run:
  pod install
Enter fullscreen mode Exit fullscreen mode

This command reads the Podfile and installs the necessary dependencies into a Pods directory.


2. Install Xcode

Xcode is the IDE used to build, test, and deploy iOS applications.

  • Install Xcode: Download it from the Mac App Store.
  • Ensure Compatibility: Ensure the installed version of Xcode is compatible with your iOS project and dependencies.
  • Command Line Tools: Install Xcode Command Line Tools by running:
  xcode-select --install
Enter fullscreen mode Exit fullscreen mode

3. Signing Certificate

iOS apps must be signed with a valid certificate to run on devices or be submitted to the App Store.

  • Generate a Certificate:

    • Go to Apple Developer Account.
    • Navigate to Certificates, Identifiers & Profiles, and create a new signing certificate.
  • Import Certificate to Keychain:


    Download the .p12 file and double-click it to add it to your macOS Keychain.


4. Provisioning Profile

The provisioning profile connects your app ID, signing certificate, and device(s) for testing or distribution.

  • Generate a Provisioning Profile:

    • In the Apple Developer account, create a profile for either development or distribution.
    • Associate the profile with your app ID and the necessary devices (for development).
  • Download and Use the Profile:


    Download the .mobileprovision file and add it to your project in Xcode.


5. Submit via TestFlight

TestFlight is Apple's official tool for beta testing iOS apps.

  • Build and Archive the App:

    In Xcode, go to Product > Archive to create an archive of your app.

  • Handle Two-Factor Authentication (2FA):

    Apple requires 2FA for logging into App Store Connect. This can pose challenges in a CI/CD pipeline.

    Solution: Use an API key to authenticate instead of an Apple ID. Here’s an example using Fastlane:

  api_key = app_store_connect_api_key({
    key_id: "<key id>",
    issuer_id: "<issuer id>",
    key_filepath: "path/to/AuthKey.p8"
  })
Enter fullscreen mode Exit fullscreen mode
  • Upload to TestFlight: Once authenticated, upload the build using Xcode or Fastlane:
  fastlane pilot upload
Enter fullscreen mode Exit fullscreen mode

6. App Versioning

Apple enforces strict versioning rules for apps on the App Store.

  • Update the Version Number:

    Each release must have a new version. Unlike npm packages, the same version cannot be reused.

    • Update the version in Xcode: General > Version.
    • Use semantic versioning (e.g., 1.0.1, 1.1.0).
  • Automate Version Updates:


    Use Fastlane to increment the version number:

  fastlane run increment_version_number
Enter fullscreen mode Exit fullscreen mode

Now you can build, sign, and submit your iOS app while addressing common challenges like signing, provisioning, and two-factor authentication for App Store submission.

After knowing how to build for every platform manually, we can start automating the build process using GitHub Actions and Fastlane.

Fastlane: Automating iOS and Android Builds

Fastlane is a popular tool for automating repetitive tasks in mobile app development, such as building, signing, and deploying apps. It simplifies CI/CD workflows and reduces manual overhead.


  • What is Fastlane?

    Fastlane is an open-source platform that automates the deployment process for iOS and Android apps.

  • Language:

    Fastlane is written in Ruby, and its configuration is managed through specific files like Fastfile, Appfile, and Matchfile.


2. Fastfile

The Fastfile defines lanes, which are sequences of tasks you want to automate. Each lane can perform specific actions like building, testing, or deploying.

Example Fastfile:

default_platform(:ios)

platform :ios do
  desc "Build and deploy to TestFlight"
  lane :beta do
    build_app(scheme: "MyApp")       # Build the app
    upload_to_testflight             # Upload to TestFlight
  end
end
Enter fullscreen mode Exit fullscreen mode
  • Key Components:
    • build_app: Compiles the app.
    • upload_to_testflight: Submits the app to TestFlight.

And a lot of other actions can be found in the Fastlane documentation.


3. Appfile

The Appfile contains metadata about the app, such as the app identifier and developer account details.

Example Appfile:

app_identifier("com.example.app")  # Dynamically set the app ID
apple_id("developer@example.com")  # Your Apple Developer account email

itc_team_id("ABC123")  # App Store Connect Team ID
team_id("DEF456")  # Developer Team ID
Enter fullscreen mode Exit fullscreen mode

4. Certificates and Provisioning Profiles

Fastlane uses the match action to manage and install certificates and provisioning profiles. This eliminates the need to manually download and install these files.

  • Certificates and Profiles Bottleneck: Storing certificates and profiles directly in the repository is insecure. Fastlane resolves this by storing them in a secure, version-controlled Git repository.

Matchfile Example:

git_url("https://username:#{ENV["GH_ACCESS_TOKEN"]}@github.com/your-org/certificates-repo.git")
type("appstore")                       # Type of profile: appstore, adhoc, development
app_identifier(ENV["APP_IDENTIFIER"])  # get app ID from env
username("developer@example.com")      # Apple Developer account
Enter fullscreen mode Exit fullscreen mode

As you can see here, I created a separate private repo for certificates and profiles so that match can use to automatically store and retrieve them when needed.

  • Installation in CI:
  fastlane match appstore
Enter fullscreen mode Exit fullscreen mode

5. Authentication with .p8 Auth Key

For App Store submission, Apple requires authentication. The .p8 Auth Key provides a secure, CI-friendly alternative to two-factor authentication (2FA).

Using the Auth Key in Fastlane:

api_key = app_store_connect_api_key({
  key_id: "<YOUR_KEY_ID>",
  issuer_id: "<YOUR_ISSUER_ID>",
  key_filepath: "path/to/AuthKey.p8"
})
Enter fullscreen mode Exit fullscreen mode

6. Dynamically Handling Multiple App IDs

Managing app IDs dynamically is crucial when building multiple apps without hardcoding the identifiers. The app ID, such as com.x.y, is required in every Fastlane configuration.

Solution: Use environment variables to dynamically assign the app ID in the CI pipeline.

  • Set the App ID:
  export APP_IDENTIFIER="com.example.myapp"
Enter fullscreen mode Exit fullscreen mode
  • Use in Fastlane:
  app_identifier(ENV["APP_IDENTIFIER"])
Enter fullscreen mode Exit fullscreen mode

Now we are hardcoded the app ID in the Fastfile. But this can be a challenge when automating the build process for multiple apps. We'll come back to this later.


Fastfile explained

The building blocks for the Fastfile is lanes which are like methods in OOP paradigm. Each lane is a collection of tasks that are executed in a specific order. These lanes can be executed with the fastlane run <lane_name> command.

Let's walk through the Fastfile in detail:


Global Configuration

  1. default_platform(:ios)
    • Sets the default platform to :ios so that you don't need to explicitly specify it when running lanes. This is helpful if you're only working with iOS or if it's the primary platform.

Platform Block: platform :ios do

This block contains all the lanes specific to the iOS platform.

Lane: :prepare

This lane prepares the environment for building the app, such as setting up keychains, certificates, and provisioning profiles.

Steps in the :prepare lane:

  1. Define the path for the AuthKey file:
   auth_key_path = File.expand_path(File.join(File.dirname(__FILE__), '<name of AuthKey file>.p8'))
Enter fullscreen mode Exit fullscreen mode
  • Defines the absolute path to the .p8 authentication key file required for App Store Connect API.
  1. Create an API key object:
   api_key = app_store_connect_api_key({
     key_id: "<key id>",
     issuer_id: "<issuer id>",
     key_filepath: auth_key_path,
   })
Enter fullscreen mode Exit fullscreen mode
  • Authenticates with App Store Connect using the specified key_id, issuer_id, and key_filepath.
  • This step is crucial to authenticate with apple so that you can upload the app to App Store Connect without two-factor authentication.
  1. Create a keychain:
   create_keychain(
     name: 'ios_app_keychain',
     password: ENV['KEYCHAIN_PASSWORD'],
     timeout: 1800,
     default_keychain: true,
     unlock: true,
     lock_when_sleeps: false,
     add_to_search_list: true
   )
Enter fullscreen mode Exit fullscreen mode
  • Creates a custom keychain for storing signing certificates and provisioning profiles. The keychain:
    • Uses the password stored in the KEYCHAIN_PASSWORD environment variable. Which we will set in the workflow file.
    • Is set as the default keychain and unlocked for 30 minutes (timeout: 1800).
  1. Fetch certificates and provisioning profiles using match:
   match(
     type: 'appstore',
     app_identifier: ENV["APP_IDENTIFIER"],
     readonly: false,
     keychain_name: 'ios_app_keychain', # Use the previously created keychain, notice that it's the same name as in create_keychain step
     keychain_password: ENV['KEYCHAIN_PASSWORD'],
     api_key: api_key
   )
Enter fullscreen mode Exit fullscreen mode
  • Retrieves the required certificates and provisioning profiles for the appstore type.
  • Uses the api_key for authentication and installs the profiles in the created keychain.
  1. Update project provisioning:
   update_project_provisioning(
     xcodeproj: "<name of the app>.xcodeproj",
     target_filter: "<name of the app>",
     profile: ENV["sigh_#{ENV["APP_IDENTIFIER"]}_appstore_profile-path"],
     build_configuration: "Release"
   )
Enter fullscreen mode Exit fullscreen mode
  • Updates the Xcode project with the appropriate provisioning profile for the Release configuration.
  • Notice the ENV["sigh_#{ENV["APP_IDENTIFIER"]}_appstore_profile-path"] which is the path to the provisioning profile stored in the env variables used by fastlane. The syntax is sigh_<app_identifier>_<type>_<profile-name> so here in our case it's sigh_com.example.app.appstore_profile-name which is name of the env variable used by fastlane to get the path to provisioning profile (which is something handled by match).

Lane: :beta

This lane is used to push a new beta build to TestFlight.

Steps in the :beta lane:

  1. Define the path for the AuthKey file:

    Same as in the :prepare lane.

  2. Create an API key object:

    Same as in the :prepare lane.

  3. Retrieve the current build number:

   build_number = app_store_build_number(api_key: api_key, live: false)
Enter fullscreen mode Exit fullscreen mode
  • Fetches the latest build number for the app on TestFlight (non-live version) so that we can increment it for the next build.
  1. Increment the build number:
   increment_build_number(xcodeproj: "<name of the app>.xcodeproj", build_number: build_number + 1)
Enter fullscreen mode Exit fullscreen mode
  • Updates the build number in the Xcode project file (<name of the app>.xcodeproj) by incrementing it by 1. The <name of the app> should be replaced with the actual name of your app.
  1. Update code signing settings:
   update_code_signing_settings(
     use_automatic_signing: false,
     path: "<name of the app>.xcodeproj",
     build_configurations: ["Release"]
   )
Enter fullscreen mode Exit fullscreen mode
  • Configures the Xcode project to use manual code signing for the Release configuration. This is required for submitting the app to TestFlight.
  1. Build the app using gym:
   gym(
     workspace: "<name of the app>.xcworkspace",
     scheme:"<name of the app>",
     configuration: "Release",
     clean: true,
     export_method: "app-store",
     output_directory:"./build/",
     export_options: {
       method: "app-store",
       provisioningProfiles: { 
         ENV["APP_IDENTIFIER"] => ENV["sigh_#{ENV["APP_IDENTIFIER"]}_appstore_profile-name"] # Dynamically set the provisioning profile env variable
       }
     },
     codesigning_identity: "<signing identity>", # get it from apple connect
     output_name: "test_prod.ipa"
   )
Enter fullscreen mode Exit fullscreen mode
  • Builds the app with the following parameters:
    • Workspace: <name of the app>.xcworkspace.
    • Scheme: <name of the app>.
    • Configuration: Release.
    • Provisioning Profiles: Dynamically sets the provisioning profile based on environment variables.
    • Output: Saves the .ipa file as test_prod.ipa in the ./build/ directory.
  1. Upload the build to TestFlight:
   upload_to_testflight(api_key: api_key)
Enter fullscreen mode Exit fullscreen mode
  • Submits the .ipa to TestFlight for testing, skipping the wait for Apple's processing.

Now the final Fastfile is:

default_platform(:ios)

platform :ios do

  desc "Push a new beta build to TestFlight"
  lane :beta do
    auth_key_path = File.expand_path(File.join(File.dirname(__FILE__), '<name of AuthKey file>.p8'))
    api_key = app_store_connect_api_key({
      key_id: "<key id>",
      issuer_id: "<issuer id>",
      key_filepath: auth_key_path,
    })
    build_number = app_store_build_number(api_key: api_key, live: false)
    increment_build_number(xcodeproj: "<name of the app>.xcodeproj", build_number: build_number + 1)
    update_code_signing_settings(
      use_automatic_signing: false,
      path: "<name of the app>.xcodeproj",
      build_configurations: ["Release"]
    )
    gym(
      workspace: "<name of the app>.xcworkspace",
      scheme:"<name of the app>",
      configuration: "Release",
      clean: true,
      export_method: "app-store",
      output_directory:"./build/",
      export_options: {
        method: "app-store",
        provisioningProfiles: { 
          ENV["APP_IDENTIFIER"] => ENV["sigh_#{ENV["APP_IDENTIFIER"]}_appstore_profile-name"]
        }
      },
      codesigning_identity: "<signing identity>", # get it from apple connect
      output_name: "test_prod.ipa"
    )
    upload_to_testflight(api_key: api_key)
  end

  lane :prepare do
    auth_key_path = File.expand_path(File.join(File.dirname(__FILE__), '<name of AuthKey file>.p8'))
    api_key = app_store_connect_api_key({
      key_id: "<key id>",
      issuer_id: "<issuer id>",
      key_filepath: auth_key_path,
    })
    create_keychain(
      name: 'ios_app_keychain',
      password: ENV['KEYCHAIN_PASSWORD'],
      timeout: 1800,
      default_keychain: true,
      unlock: true,
      lock_when_sleeps: false,
      add_to_search_list: true
    )

    match(
      type: 'appstore',
      app_identifier: ENV["APP_IDENTIFIER"],
      readonly: false,
      keychain_name: 'ios_app_keychain',
      keychain_password: ENV['KEYCHAIN_PASSWORD'],
      api_key: api_key
    )

    update_project_provisioning(
      xcodeproj: "<name of the app>.xcodeproj",
      target_filter: "<name of the app>",
      profile: ENV["sigh_#{ENV["APP_IDENTIFIER"]}_appstore_profile-path"],
      build_configuration: "Release"
    )
  end
end

Enter fullscreen mode Exit fullscreen mode

We now have a good grasp of what in the Fastfile and how it works. With this knowledge, we can now use the Fastfile to build and deploy the app to TestFlight.

But we still have 2 issues for our CI/CD pipeline to work:

  1. Hardcoded app ID in the Fastfile, Matchfile and Appfile.
  2. Creating the keystore file in the repository for android build to work.

Let's address these issues while we are working on the workflow for GitHub Actions.

Github workflow file

Build Android version and submit to store

The requirement is to build the android version with 2 versions of the app, one for production (.aab) and one for staging (.apk). We'll use action's conditions here for this requirement.

  1. Setup node: we'll use the actions/setup-node@v2 action to set up Node.js environment.
   - name: Setup Node
     uses: actions/setup-node@v2
     with:
       node-version: '20.16.0'
Enter fullscreen mode Exit fullscreen mode
  1. Setup Java: we'll use the actions/setup-java@v4 action to set up Java environment.
   - name: Setup Java
     uses: actions/setup-java@v4
     with:
       distribution: 'zulu'
       java-version: '17'
Enter fullscreen mode Exit fullscreen mode
  1. Validate Gradle wrapper: we'll use the gradle/wrapper-validation-action@v1 action to validate the Gradle wrapper.
   - name: Validate Gradle wrapper
     uses: gradle/wrapper-validation-action@v1
Enter fullscreen mode Exit fullscreen mode
  1. Install Deps:
   - name: Install Deps
     shell: bash
     run: yarn --frozen-lockfile && yarn env
Enter fullscreen mode Exit fullscreen mode
  1. Create keystore file: the keystore file should not be pushed to the repository. to achieve this, we'll do the following:

    • Encrypt the local keystore file using base64 encoding.
    • Add this encrypted keystore file to the secrets.
    • Use the mobiledevops/secret-to-file-action@v1 action to create the keystore file.
    - name: Create keystore file
      uses: mobiledevops/secret-to-file-action@v1
      with:
        base64-encoded-secret: ${{ secrets.KEYSTORE_FILE }}
        filename: '<name of the app>.keystore'
        is-executable: false
        working-directory: './android/app'
    
  2. Generate apk: we'll run the yarn g-c-build (which is an npm script in our package.json file) command to generate the .apk file if the pipeline is in the dev branch.

   - name: Generate apk
     if: github.ref_name == env.DEV_BRANCH
     run: yarn g-c-build
Enter fullscreen mode Exit fullscreen mode
  1. Generate aab: we'll run the yarn g-c-build (which is an npm script in our package.json file) command to generate the .aab file if the pipeline is not in the dev branch.
   - name: Generate aab
     if: github.ref_name != env.DEV_BRANCH
     run: yarn g-c-bundle
Enter fullscreen mode Exit fullscreen mode
  1. Upload the .apk file to firebase for QA to download and install it. This will require us to set the app id from firebase as an env variable to use it while pushing to firebase using wzieba/Firebase-Distribution-Github-Action@v1 action (optional).
   - name: Set App ID taken from firebase as env variable
     if: github.ref_name == env.DEV_BRANCH
     id: set-app-id
     run: |
       echo FIREBASE_APP_ID=$(cat android/firebase_app_id.txt) >> $GITHUB_OUTPUT

   - name: Upload artifact to Firebase App Distribution
     if: github.ref_name == env.DEV_BRANCH
     uses: wzieba/Firebase-Distribution-Github-Action@v1
     with:
       appId: ${{ env.FIREBASE_APP_ID }}
       serviceCredentialsFileContent: ${{ secrets.CREDS_FILE_CONTENT }}
        groups: testers
       file: android/app/build/outputs/apk/release/app-release.apk
     env:
       FIREBASE_APP_ID: ${{ steps.set-app-id.outputs.FIREBASE_APP_ID }}
Enter fullscreen mode Exit fullscreen mode

Notice here that I stored the app id from firebase in a text file called firebase_app_id.txt and used it to set the app id as an env variable. Also, the $GITHUB_OUTPUT variable is used to store the output of the echo command which is a reserved variable in GitHub Actions.

  1. Upload the .aab file to google play using KevinRohn/github-action-upload-play-store@v1.0.0 action.
   - name: Upload artifact to Google Play
     if: github.ref_name != env.DEV_BRANCH
     uses: KevinRohn/github-action-upload-play-store@v1.0.0
     with:
       service_account_json: ${{ secrets.SERVICE_ACCOUNT_JSON }}
       package_name: ${{ github.ref_name }} # will explain this later in ios steps
       aab_file_path: 'android/app/build/outputs/bundle/release/app-release.aab'
       track: 'production'
       release_status: 'completed'
Enter fullscreen mode Exit fullscreen mode

Build IOS version and submit to store

  1. Setup Node: we'll use the actions/setup-node@v2 action to set up Node.js environment. Same as in android.
   - name: Setup Node
     uses: actions/setup-node@v2
     with:
       node-version: '20.16.0'
Enter fullscreen mode Exit fullscreen mode
  1. Install Deps: same as in android.
   - name: Install Deps
     shell: bash
     run: yarn --frozen-lockfile && yarn env
Enter fullscreen mode Exit fullscreen mode
  1. Install pods:
   - name: Install pods
     shell: bash
     run: |
       cd ios/
       rm -rf Pods Podfile.lock
       pod install --repo-update
Enter fullscreen mode Exit fullscreen mode
  1. Run fastlane's prepare and beta actions with required env variables.
   - name: Prepare and build IOS
     run: cd ios/ && fastlane prepare && fastlane beta
     env:
        FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
        FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
        P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
        KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
        MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
        ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: false
        APP_IDENTIFIER: ${{ github.ref_name }} # we will use the branch name as app id
Enter fullscreen mode Exit fullscreen mode
Notice here that we used the branch name as app id so that we have addressed the issue of hardcoded app id in the Fastfile, Appfile and Matchfile. For each client we'll have a different app id. For example, if we have a client app id in apple called `com.example.client` then we'll name the branch `com.example.client` and the app id will be `com.example.client` by using `github.ref_name`.
Enter fullscreen mode Exit fullscreen mode

The resulting workflow will look like this:

name: Build & submit to stores
on:
  workflow_dispatch:

env:
  DEV_BRANCH: 'staging'

jobs:
  build-ios:
    name: Build IOS
    runs-on: macos-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v1

      - name: Setup Node
        uses: actions/setup-node@v2
        with:
          node-version: '20.16.0'

      - name: Install Deps
        shell: bash
        run: yarn install --ignore-scripts && yarn env

      - name: Install pods
        shell: bash
        run: |
          cd ios/
          rm -rf Pods Podfile.lock
          pod install --repo-update

      - name: Prepare and build IOS
        run: cd ios/ && fastlane prepare && fastlane beta
        env:
          FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
          FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: false
          APP_IDENTIFIER: ${{ github.ref_name }}


  build-android:
    name: Build Android
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v1

      - name: Setup Node
        uses: actions/setup-node@v2
        with:
          node-version: '20.16.0'

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: '17'

      - name: Validate Gradle wrapper
        uses: gradle/wrapper-validation-action@v1

      - name: Install Deps
        shell: bash
        run: yarn --frozen-lockfile && yarn env

      - name: Create keystore file
        uses: mobiledevops/secret-to-file-action@v1
        with:
          base64-encoded-secret: ${{ secrets.KEYSTORE_FILE }}
          filename: '<app name>.keystore'
          is-executable: false
          working-directory: './android/app'

      - name: Generate apk
        if: github.ref_name == env.DEV_BRANCH
        run: yarn g-c-build

      - name: Generate aab
        if: github.ref_name != env.DEV_BRANCH
        run: yarn g-c-bundle

      - name: Set App ID taken from firebase as env variable
        if: github.ref_name == env.DEV_BRANCH
        id: set-app-id
        run: |
          echo FIREBASE_APP_ID=$(cat android/firebase_app_id.txt) >> $GITHUB_OUTPUT

      - name: Upload artifact to Firebase App Distribution
        if: github.ref_name == env.DEV_BRANCH
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ env.FIREBASE_APP_ID }}
          serviceCredentialsFileContent: ${{ secrets.CREDS_FILE_CONTENT }}
          groups: testers
          file: android/app/build/outputs/apk/release/app-release.apk
        env:
          FIREBASE_APP_ID: ${{ steps.set-app-id.outputs.FIREBASE_APP_ID }}

      - name: Upload artifact to Google Play
        if: github.ref_name != env.DEV_BRANCH
        uses: KevinRohn/github-action-upload-play-store@v1.0.0
        with:
          service_account_json: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          package_name: ${{ github.ref_name }}
          aab_file_path: 'android/app/build/outputs/bundle/release/app-release.aab'
          track: 'production'
          release_status: 'completed'
Enter fullscreen mode Exit fullscreen mode

We made the pipeline to run manually by using workflow_dispatch action, which makes it easy to trigger the workflow manually which is good for minutes consumption. Also this event will help us trigger the workflow run through github APIs as well.

After we ran this workflow, we can see the artifacts uploaded to firebase and google play stores and also to testflight. But, we saw that the minutes consumption is 40 minutes for ios and 27 minutes for android which is very high.

Optimizing the workflow

To reduce the consumption time, we can do the following:

  1. Cache dependencies: we can use the actions/cache action to cache the dependencies to speed up the workflow.
   - name: Cache node_modules
     id: modules-cache
     uses: actions/cache@v3
     with:
        path: ./node_modules
        key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
        restore-keys: |
        ${{ runner.os }}-modules-
Enter fullscreen mode Exit fullscreen mode

This will cache the dependencies and only install them if they are not cached. Also skip the install step if the dependencies are cached.

   - name: Install Deps if necessary
     if: steps.modules-cache.outputs.cache-hit != 'true'
     shell: bash
     run: yarn install --ignore-scripts && yarn env 
Enter fullscreen mode Exit fullscreen mode
  1. Cache pods: we can use the actions/cache action to cache the pods to speed up the workflow.
   - name: Cache pods
     id: pods-cache
     uses: actions/cache@v3
     with:
        path: ./ios/Pods
        key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
        restore-keys: |
        ${{ runner.os }}-pods-
Enter fullscreen mode Exit fullscreen mode

This will cache the pods and only install them if they are not cached. Also skip the install step if the pods are cached.

   - name: Install pods if necessary
     if: steps.pods-cache.outputs.cache-hit != 'true'
     shell: bash
     run: |
        cd ios/
        rm -rf Pods Podfile.lock
        pod install --repo-update
Enter fullscreen mode Exit fullscreen mode
  1. Skip waiting for build to be processed in testflight: In the Fastfile we used the upload_to_testflight action which will wait for the build to be processed in testflight. But we can skip this step by using the skip_waiting_for_build_processing: true option.
   upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
Enter fullscreen mode Exit fullscreen mode

The resulting workflow will look like this:

name: Build & submit to stores
on:
  workflow_dispatch:

env:
  DEV_BRANCH: 'staging'

jobs:
  build-ios:
    name: Build IOS
    runs-on: macos-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v1

      - name: Setup Node
        uses: actions/setup-node@v2
        with:
          node-version: '20.16.0'

      - name: Cache node_modules
        id: modules-cache
        uses: actions/cache@v3
        with:
          path: ./node_modules
          key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-modules-

      - name: Install Deps if necessary
        if: steps.modules-cache.outputs.cache-hit != 'true'
        shell: bash
        run: yarn install --ignore-scripts && yarn env

      - name: Cache Pods
        uses: actions/cache@v3
        id: pods-cache
        with:
          path: ./ios/Pods
          key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
          restore-keys: |
            ${{ runner.os }}-pods-

      - name: Install pods
        if: steps.pods-cache.outputs.cache-hit != 'true'
        shell: bash
        run: |
          cd ios/
          rm -rf Pods Podfile.lock
          pod install --repo-update
          cd ..
      - name: Prepare and build IOS
        if: github.ref_name != env.DEV_BRANCH
        run: cd ios/ && fastlane prepare && fastlane beta
        env:
          FASTLANE_USER: ${{ secrets.FASTLANE_USER }}
          FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }}
          P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          ITMSTRANSPORTER_FORCE_ITMS_PACKAGE_UPLOAD: false
          APP_IDENTIFIER: ${{ github.ref_name }}


  build-android:
    name: Build Android
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v1

      - name: Setup Node
        uses: actions/setup-node@v2
        with:
          node-version: '20.16.0'

      - name: Setup Java
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: '17'

      - name: Validate Gradle wrapper
        uses: gradle/wrapper-validation-action@v1

      - name: Cache node_modules
        id: modules-cache
        uses: actions/cache@v3
        with:
          path: ./node_modules
          key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-modules-

      - name: Install Deps if necessary
        if: steps.modules-cache.outputs.cache-hit != 'true'
        shell: bash
        run: yarn --frozen-lockfile && yarn env

      - name: Create keystore file
        uses: mobiledevops/secret-to-file-action@v1
        with:
          base64-encoded-secret: ${{ secrets.KEYSTORE_FILE }}
          filename: '<app name>.keystore'
          is-executable: false
          working-directory: './android/app'

      - name: Generate apk
        if: github.ref_name == env.DEV_BRANCH
        run: yarn g-c-build

      - name: Generate aab
        if: github.ref_name != env.DEV_BRANCH
        run: yarn g-c-bundle

      - name: Set App ID taken from firebase as env variable
        if: github.ref_name == env.DEV_BRANCH
        id: set-app-id
        run: |
          echo FIREBASE_APP_ID=$(cat android/firebase_app_id.txt) >> $GITHUB_OUTPUT

      - name: Upload artifact to Firebase App Distribution
        if: github.ref_name == env.DEV_BRANCH
        uses: wzieba/Firebase-Distribution-Github-Action@v1
        with:
          appId: ${{ env.FIREBASE_APP_ID }}
          serviceCredentialsFileContent: ${{ secrets.CREDS_FILE_CONTENT }}
          groups: testers
          file: android/app/build/outputs/apk/release/app-release.apk
        env:
          FIREBASE_APP_ID: ${{ steps.set-app-id.outputs.FIREBASE_APP_ID }}

      - name: Upload artifact to Google Play
        if: github.ref_name != env.DEV_BRANCH
        uses: KevinRohn/github-action-upload-play-store@v1.0.0
        with:
          service_account_json: ${{ secrets.SERVICE_ACCOUNT_JSON }}
          package_name: ${{ github.ref_name }}
          aab_file_path: 'android/app/build/outputs/bundle/release/app-release.aab'
          track: 'production'
          release_status: 'completed'
Enter fullscreen mode Exit fullscreen mode

Now, the resulting workflow will run in 20-30 minutes instead of 40 minutes which reduces the minutes consumbtion by 20%-50%.

Conclusion

Automating the build and release process with GitHub Actions and Fastlane transformed a time-intensive bottleneck into a streamlined, scalable solution. This pipeline is a testament to the power of automation in solving real-world challenges, enabling teams to focus on what matters most: building great products.

If you're managing multiple apps or facing similar bottlenecks, consider leveraging GitHub Actions and Fastlane. It’s not just about saving time—it’s about empowering your team to achieve more.

Top comments (1)

Collapse
 
abdumamdouh profile image
Abdulrahman Mamdouh

Great job