Smoothing Out The Bumps: My Journey of Setting Up CI/CD for Android and iOS Apps

Veeki
8 min readOct 31, 2023

--

Illustration depicting CI/CD process flow

Never spend 6 minutes doing something by hand when you can spend 6 hours failing to automate it

In the realm of app development, releasing your creations into the wild can be a tedious task. However, it doesn’t have to be. Enter the world of Continuous Integration and Continuous Deployment (CI/CD), a developer’s knight in shining armor when it comes to automating the build and deployment process. Through CI/CD, developers can automate testing and deployment, ensuring a seamless transition from code to production.

My recent adventure into setting up a CI/CD pipeline for both Android and iOS applications on GitLab led me down a path filled with learning, and a little head-scratching. Below, I’ll share the CI/CD pipeline I established for building and deploying Android and iOS builds to the Google Play Console and Apple App Store, respectively.

Understanding the CI/CD File

Our journey begins with the CI/CD file, the blueprint for automating the entire process. Let’s break down the major components:

1. Setting the Base Image and Variables

image: reactnativecommunity/react-native-android

variables:
LC_ALL: 'en_US.UTF-8'
LANG: 'en_US.UTF-8'

Here, we specify the Docker image and set some global variables. reactnativecommunity/react-native-android is our base image, and we're setting the locale and language settings which will be used throughout the pipeline.

2. Defining the Stages

stages:
- setup
- format
- lint
- build_android
- build_ios

Stages represent the different phases our code will go through. They will be executed in the order listed, starting with setup, then formatting, linting, and finally, building and deploying for Android and iOS.

3. Preparing the Ground: Setup Stage

.setup_template: &setup_template
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
...
setup:
stage: setup
script:
- yarn install
<<: *setup_template
only:
- merge_requests

In the setup stage, we install the project dependencies using yarn install. The cache is configured to save node_modules for later stages, reducing the build time.

4. Format Stage

format_job:
stage: format
script:
- yarn format-check
<<: *setup_template
only:
- merge_requests

In this format_job stage:

  1. stage: format: Specifies the stage name as format.
  2. script: - yarn format-check: Executes a script that checks code formatting using Yarn. It's vital to ensure consistent code formatting for readability and maintainability.
  3. <<: *setup_template: Inherits the cache setup from the setup_template defined earlier to reuse the node_modules cache, speeding up the job.
  4. only: - merge_requests: Specifies that this job should only run for merge requests, helping catch formatting issues before code gets merged.

5. Lint Stage

lint_job:
stage: lint
script:
- git fetch --force origin $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME:$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
- git fetch --force origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME:$CI_MERGE_REQUEST_TARGET_BRANCH_NAME
- |
CHANGED_FILES=$(git diff --name-only --diff-filter=d $CI_MERGE_REQUEST_TARGET_BRANCH_NAME...$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME | grep -E '\.ts$|\.tsx$|\.js$|\.jsx$') || true
echo "Git diff exit status: $?"
if [[ -n "$CHANGED_FILES" ]]; then
echo "Changed files: $CHANGED_FILES"
echo "$CHANGED_FILES" | xargs yarn lint
else
echo "No files changed"
fi
<<: *setup_template
only:
- merge_requests

In the lint_job stage:

  1. stage: lint: Names this stage as lint.
  2. The script block does the following:
  • Fetches the source and target branches of the merge request.
  • Compares the branches to identify changed files.
  • If there are changed JavaScript or TypeScript files, it lints them using Yarn.

3. <<: *setup_template: Inherits the cache setup to reuse node_modules.

4. only: - merge_requests: This job runs only for merge requests, ensuring linting checks are done before code is merged.

6. Build Stages

Before moving to individual Android and IOS build stages, we should discuss another important tool called Fastlane .

Fastlane

Fastlane is a tool that automates beta deployments and releases for iOS and Android apps. It handles tasks like generating screenshots, dealing with code signing, and releasing your application.

Setting Up Fastlane

  • Install: using RubyGems by running the following command:
gem install fastlane -NV
  • Initialisation: Navigate to you project repository in the termial and run the following command:
# For IOS, it would be inside ios/
# For Android, it would be inside android/

fastlane init
  • Configuration

After initializing Fastlane in your project directory, you’d have a fastlane folder with an Appfile and a Fastfile. Configure these files according to your project needs.

  • Fastfile defines the lanes or workflows for your build process.
  • Appfile stores configuration values that are global to your project.

Below is an example of Appfile for both IOS and Android for a better idea.

// Android: Usually would have
json_key_file("path/to/json/key/file.json") # Path to the json secret file
package_name("com.yourcompany.yourapp") # Your app's package name

// IOS: Usually would have
app_identifier("com.yourcompany.yourapp") # Your app's bundle identifier
apple_id("email@example.com") # Your Apple email address
team_id("TEAMID") # Developer Portal Team ID
  • Fastlane Match

We all know that code signing is an integral part of iOS and can sometimes leave you scratching your head. To address this issue, Fastlane introduced Match, an iOS code signing utility.

Fastlane Match creates and maintains a single, shared repository of signing certificates and provisioning profiles, which can be used across your development team.

Note: Fastlane Match isn’t necessary for working with Fastlane but simplifies your code signing process significantly.

  • Setup Match: Run the following command within ios/ directory.
fastlane match init
  • Configuration: Configure the Matchfile with your repository and branching details.
fastlane match development
  • Usage: In your Fastfile, use the match action to fetch the appropriate certificates and profiles for building your app.
// Example of match action
match(
app_identifier: ENV["APP_IDENTIFIER"],
type: "appstore",
readonly: is_ci, // if used in CI/CD
)

Now that we’ve been introduced to Fastlane, let’s delve into the actual build processes for Android and iOS in our CI/CD pipeline.

Android Build Process

  • Stage Definition
build_android_job:
stage: build_android
before_script:
- apt-get update -y && apt-get install -y curl git zlib1g-dev autoconf bison build-essential
libssl-dev libyaml-dev libreadline6-dev zlib1g-dev libncurses5-dev libffi-dev libgdbm6 libgdbm-dev libdb-dev
- curl -fsSL https://github.com/rbenv/rbenv-installer/raw/HEAD/bin/rbenv-installer | bash
- export PATH="$HOME/.rbenv/bin:$PATH"
- eval "$(rbenv init -)"
- rbenv install 3.2.2
- rbenv global 3.2.2
script:
- yarn install
- cd android
- gem install bundler
- bundle install
- echo $APKSIGN_KEYSTORE_BASE64 | base64 -d > release.jks
- export APKSIGN_KEYSTORE=`pwd`/release.jks
- echo $APP_PLAY_SERVICE_JSON > app/service.json
- bundle exec fastlane upload_internal
when: manual

In the build_android_job stage:

  1. stage: build_android: Specifies this as the Android build stage.
  2. before_script: This block contains commands to set up the environment.
  • The first command updates the package list and installs essential packages including curl, git, and others needed for the build process.
  • The second command installs rbenv, a Ruby version manager, which is essential for running Fastlane.
  • Other setup commands follow to prepare the environment for the Android build.

3. script: This block contains the script to build and upload the Android app.

  • bundle exec fastlane upload_internal: This command triggers Fastlane to run the upload_internal lane, which handles building and uploading the Android app.

4. when: manual: Specifies that this job should only run when triggered manually.

  • Fastlane Configuration

Fastlane configuration for Android is contained within a Fastfile. Here's a simplified version of the upload_internal lane:

desc "Submit a new Internal Build to Play Store"
lane :upload_internal do
gradle(
task: "bundle",
build_type: "release",
properties: {
"android.injected.signing.store.file" => File.expand_path(ENV['APKSIGN_KEYSTORE']),
"android.injected.signing.store.password" => ENV['APKSIGN_KEYSTORE_PASS'],
"android.injected.signing.key.alias" => ENV['APKSIGN_KEY_ALIAS'],
"android.injected.signing.key.password" => ENV['APKSIGN_KEY_PASS'],
}
)
upload_to_play_store(
track: 'internal',
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true
)
end

In the build_android_job stage:

  1. gradle: This command invokes Gradle to build the app.
  • task: "bundle": Specifies to create a bundle.
  • build_type: "release": Indicates a release build.
  • properties: Contains properties for signing the app.
  • android.injected.signing.store.file: Path to the keystore file.
  • android.injected.signing.store.password: Keystore password.
  • android.injected.signing.key.alias: Key alias.
  • android.injected.signing.key.password: Key password.
  • ENV['APKSIGN_KEYSTORE'], ENV['APKSIGN_KEYSTORE_PASS'], ENV['APKSIGN_KEY_ALIAS'], ENV['APKSIGN_KEY_PASS']: These environment variables store sensitive data for signing the app. They should be configured in your CI/CD environment securely.

2. upload_to_play_store: This command uploads the app to the Play Store.

  • track: 'internal': Specifies to upload to the internal track.
  • skip_upload_metadata, skip_upload_images, skip_upload_screenshots: These flags skip uploading metadata, images, and screenshots respectively, as they might not be necessary for internal builds.

IOS Build Process

  • Stage Definition
build_ios_job:
stage: build_ios
tags:
- iOS // runner-tag on which this stage would run on
script:
- yarn install
- cd ios
- bundle install
- bundle exec pod install
- bundle exec fastlane release
when: manual

In the build_ios_job stage:

  1. stage: build_ios: Specifies this as the iOS build stage.

2. script: Contains the scripts to build and upload the iOS app.

  • bundle exec fastlane release: This command triggers Fastlane to run the release lane, which handles building and uploading the iOS app.

3. when: manual: Specifies that this job should only run when triggered manually.

  • Fastlane Configuration

Below are the lanes designed to prepare your environment and ensure that all necessary certificates and profiles are in place before building and releasing the app.

  ## Setup local environment configuration. If running on a CI machine we need to create a Keychain to store the 
## distribution certificates
private_lane :setup_environment do
if is_ci
setup_ci(force: true)
end
end
  • A private lane named setup_environment. Private lanes are utility lanes that can only be called from other lanes.
  • if is_ci: Checks if Fastlane is being run on a Continuous Integration (CI) server.
  • setup_ci(force: true): Calls the setup_ci action to set up the CI environment. The force: true parameter ensures that any existing setup is overridden if necessary.
  desc "Setup Development & Distribution profiles and certificates in the local machine"
lane :setup_provisions do
setup_environment

# Install Distribution Provisions and Certificates
match(
app_identifier: ENV["APP_IDENTIFIER"],
type: "appstore",
readonly: is_ci,
)

end
  • A public lane named setup_provisions.
  • setup_environment: Calls the previously defined private lane to set up the environment.
  • match(...): Calls the match action to manage certificates and provisioning profiles.
  • app_identifier: ENV["APP_IDENTIFIER"]: Specifies the app identifier.
  • type: "appstore": Indicates that App Store provisioning profiles and certificates should be used.
  • readonly: is_ci: In a CI environment, it prevents match from creating new certificates or profiles.
  desc "Release app to Testflight"
lane :release do
api_key = app_store_connect_api_key(
key_id: ENV["APP_STORE_KEY_ID"],
issuer_id: ENV["APP_STORE_ISSUER_ID"],
key_content: ENV["APP_STORE_API_KEY"],
in_house: false,
)

setup_provisions

# Update code signing settings
update_code_signing_settings(
use_automatic_signing: false,
path: "path to .xcodeproj",
team_id: ENV["APP_STORE_DEVELOPER_PORTAL_TEAM_ID"],
code_sign_identity: "Apple Distribution",
profile_name: "match AppStore #{ENV["APP_IDENTIFIER"]}",
bundle_identifier: ENV["APP_IDENTIFIER"]
)

# Increment the version number
increment_version_number(version_number: ENV["VERSION_NUMBER"])

# Increment the build number
increment_build_number(build_number: ENV["BUILD_NUMBER"])

# Build app
build_app(workspace: "your .xcworkspace name", scheme: "your scheme name", clean: true, export_method: "app-store")

# Upload to test flight
upload_to_testflight(api_key: api_key)
end
  • A public lane named release.
  • app_store_connect_api_key: This action creates an API Key for App Store Connect, necessary for authenticating requests to upload the app to TestFlight.
  • key_id, issuer_id, key_content: These parameters are credentials obtained from your Apple Developer account. They should be stored securely and passed as environment variables.
  • Calls the setup_provisions lane defined earlier to ensure the necessary provisioning profiles and certificates are set up.
  • update_code_signing_settings: This action updates the code signing settings of your Xcode project.
  • increment_version_number(...) and increment_build_number(...): These actions increment the app’s version and build numbers.
  • build_app(...): This action builds the app using the specified workspace, scheme, and export method.
  • upload_to_testflight(api_key: api_key): This action uploads the built app to TestFlight using the specified API key for authentication.

Conclusion

After this automation-marathon, your back’s earned a diploma in endurance! Time to treat it to a victory stroll.

And voila! After what may have felt like a century in developer years, you’ve mechanized the entire build and release process. What once was a manual marathon every release day is now a breeze thanks to our trusty friend, automation. Sure, it might have taken hours, or was it days, to get this CI/CD pipeline up and running. But hey, who’s counting? Now, every code push is a VIP, escorted smoothly from commit to the app stores without breaking a sweat. So, raise a toast to the countless hours you’ve saved (or will have saved in the future)! Now, you can finally enjoy those leisurely coffee breaks, while your CI/CD pipeline diligently does the heavy lifting. Cheers to modern-day development magic!

Feel free to buy me a book to motivate me to write more.

--

--