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:
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.-
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).
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.Budget Constraints:
Third-party CI/CD solutions like CodeMagic, Bitrise, and Appcircle were unaffordable, forcing us to explore open-source and free alternatives.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:
-
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.
-
Pros:
-
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.
-
Pros:
-
Appcircle:
-
Pros:
- Supports multiple platforms.
- Offers a wide range of integrations.
- Provides a user-friendly interface.
-
Cons:
- Expensive for multiple apps.
- Limited build minutes.
-
Pros:
-
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.
-
Pros:
-
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.
-
Pros:
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
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"
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"
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
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
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'
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 }}"
To add secrets:
- Navigate to your repository settings.
- Go to Secrets and variables > Actions.
- 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
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
- Install Dependencies: Navigate to your iOS project directory and run:
pod install
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
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"
})
- Upload to TestFlight: Once authenticated, upload the build using Xcode or Fastlane:
fastlane pilot upload
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
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 likeFastfile
,Appfile
, andMatchfile
.
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
-
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
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
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
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"
})
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"
- Use in Fastlane:
app_identifier(ENV["APP_IDENTIFIER"])
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
-
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.
- Sets the default platform to
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:
- Define the path for the AuthKey file:
auth_key_path = File.expand_path(File.join(File.dirname(__FILE__), '<name of AuthKey file>.p8'))
- Defines the absolute path to the
.p8
authentication key file required for App Store Connect API.
- 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,
})
- Authenticates with App Store Connect using the specified
key_id
,issuer_id
, andkey_filepath
. - This step is crucial to authenticate with apple so that you can upload the app to App Store Connect without two-factor authentication.
- 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
)
- 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
).
- Uses the password stored in the
-
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
)
- 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.
- 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"
)
- 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 byfastlane
. The syntax issigh_<app_identifier>_<type>_<profile-name>
so here in our case it'ssigh_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 bymatch
).
Lane: :beta
This lane is used to push a new beta build to TestFlight.
Steps in the :beta
lane:
Define the path for the AuthKey file:
Same as in the:prepare
lane.Create an API key object:
Same as in the:prepare
lane.Retrieve the current build number:
build_number = app_store_build_number(api_key: api_key, live: false)
- Fetches the latest build number for the app on TestFlight (non-live version) so that we can increment it for the next build.
- Increment the build number:
increment_build_number(xcodeproj: "<name of the app>.xcodeproj", build_number: build_number + 1)
- 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.
- Update code signing settings:
update_code_signing_settings(
use_automatic_signing: false,
path: "<name of the app>.xcodeproj",
build_configurations: ["Release"]
)
- Configures the Xcode project to use manual code signing for the
Release
configuration. This is required for submitting the app to TestFlight.
-
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"
)
- 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 astest_prod.ipa
in the./build/
directory.
-
Workspace:
- Upload the build to TestFlight:
upload_to_testflight(api_key: api_key)
- 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
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:
- Hardcoded app ID in the Fastfile, Matchfile and Appfile.
- 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.
- 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'
- 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'
- 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
- Install Deps:
- name: Install Deps
shell: bash
run: yarn --frozen-lockfile && yarn env
-
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'
- Encrypt the local keystore file using
Generate apk: we'll run the
yarn g-c-build
(which is an npm script in ourpackage.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
- Generate aab: we'll run the
yarn g-c-build
(which is an npm script in ourpackage.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
- Upload the
.apk
file to firebase forQA
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 usingwzieba/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 }}
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.
- Upload the
.aab
file to google play usingKevinRohn/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'
Build IOS version and submit to store
- 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'
- Install Deps: same as in android.
- name: Install Deps
shell: bash
run: yarn --frozen-lockfile && yarn env
- Install pods:
- name: Install pods
shell: bash
run: |
cd ios/
rm -rf Pods Podfile.lock
pod install --repo-update
- Run fastlane's
prepare
andbeta
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
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`.
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'
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:
- 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-
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
- 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-
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
- 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 theskip_waiting_for_build_processing: true
option.
upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
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'
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)
Great job