Author: Gergő Fándly
Date: 2024-08-27
So you’ve been working on your shiny new React Native app for months, and now you’re ready to deploy it and share it with the public. You could do this manually for every release, but you’ve decided to do it the smart way and use a CI/CD pipeline to automate this tedious process.
In this guide I’m going to walk you through every aspect of creating an automated build pipeline using GitHub Actions both for Android and iOS, as well as generating the required signing certificates. The commands I’ll be using are executed on Linux, but they should work on MacOS as well without any changes. For Windows I’d recommend WSL.
To follow along with this guide you need to have a React Native application (if you’re using Expo, you need to eject) with its source code hosted on GitHub, and I assume you have already set up a Google Play developer account and an Apple Developer account.
Also, you should have already created the applications with the corresponding bundle IDs in App Store Connect and Google Play Console.
First of all, we need to make some changes to our application to allow automating its build process.
I’m going to start with the Android version of the application, since its human readable configuration format makes it easier to work with.
We need to make some configuration changes in android/app/build.gradle
– the main configuration file for the android build:
At the very beginning of the file insert this:
plugins {
id 'com.github.triplet.play' version '3.9.1'
}
This will install the Google Play Publisher plugin we will use to deploy the application.
After the lines starting with apply
, add these lines:
// Load the application version from the package.json file
import groovy.json.JsonSlurper
def packageJson = new JsonSlurper().parseText(file("../../package.json").text)
// Get the build number. This needs to be an integer which is incremented with
// every build. We'll be using the GITHUB_RUN_NUMBER that fits perfectly
// these requirements. And we're defaulting to 1 to allow debug builds
def buildNumber = Integer.parseInt(System.getenv("GITHUB_RUN_NUMBER") ?: "1")
Now go to the defaultConfig
block. It should look something like this:
defaultConfig {
applicationId "com.example.your_app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
You need to change versionCode
and versionName
so the block will loke like this:
defaultConfig {
applicationId "com.example.your_app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode buildNumber
versionName packageJson.version
}
Go to the signingConfigs
block (it should be right under defaultConfig
) and note that you already have a debug configuration that you have probably been using until now. We also need to add a release configuration, so paste this block right under the debug
block:
release {
if (System.getenv('ANDROID_KEYSTORE')) {
storeFile file(System.getenv('ANDROID_KEYSTORE'))
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias "upload-key"
keyPassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
}
}
Now we have to tell gradle to use our new release configuration to make release builds. Find the buildTypes
block and locate release
inside it. You will probably see a warning in there: Caution! In production, you need to generate your own keystore file. We will do just that, so change the signingConfig
parameter to signingConfigs.release
.
At the end of the file add the following configuration for the play publisher:
play {
if (System.getenv('PLAY_CREDENTIALS')) {
serviceAccountCredentials.set(file(System.getenv('PLAY_CREDENTIALS')))
}
track.set('internal')
}
This tells the plugin which credentials to use and to publish the application on the internal testing track first.
Believe it or not, that’s all we need to do for the Android application. Basically we’re setting the version string to the version from package.json
, the version code to the build number provided by GitHub Actions and we provide the code signing certificate using environment variables.
Now let’s continue with the iOS application, which can be a bit trickier. I’m not willing to use Xcode in this guide, since you can edit the files manually as well (and that would also force you to use MacOS), you just need to pay a bit more attention.
Let’s start with the harder part: open ios/<your project>.xcodeproj/project.pbxproj
and find the configuration block which contains the Pods-<your project>.release.xcconfig
comment for the baseConfigurationReference
parameter. It will be something like this:
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XXXXXXXXXX;
INFOPLIST_FILE = example/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "com.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = example;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
We have to do the following in the buildSettings
block:
DEVELOPMENT_TEAM
to the Apple Team ID you can find in the Apple Developer PortalCODE_SIGN_IDENTITY = "Apple Development";
– This sets the signing identityCODE_SIGN_STYLE = Manual;
– This enables manual signingPRODUCT_BUNDLE_IDENTIFIER
to represent exactly the identifier you’ve registered on Appstore Connect.PROVISIONING_PROFILE_SPECIFIER = $PROVISIONING_PROFILE_APP;
– This allows us to specify the provisioning profile from the CLIWe’re done with the hard part, the rest of the iOS config files are easier to manage.
Open ios/<your project>/Info.plist
add the following at the bottom of the main <dict>
:
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
This basically tells Apple that you’re not using any kind of special encryption in the application. This will help you skip some forms each time you submit an app to testflight. See more here.
And now finally create a new file and save it to ios/ExportOptions.plist
. Don’t forget to change the app id (com.example.app
) to your specific app ID:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>compileBitcode</key>
<false/>
<key>method</key>
<string>app-store</string>
<key>provisioningProfiles</key>
<dict>
<key>com.example.app</key>
<string>@PROVISIONING_PROFILE_APP@</string>
</dict>
</dict>
</plist>
To make our life a bit easier, we also add a ruby gem dependency which makes the xcodebuild output a bit more digestable. In the Gemfile
in the root of our project insert the following line:
gem 'xcpretty', '~> 0.3.0'
Both for Android and iOS you need to create code signing certificates. You can follow the guide from this post to create the certificates: Create code signing certificates for React Native.
I’m recommending to use GitHub Actions for React Native since it also offers MacOS runners and has 2000 free minutes included each month. The MacOS runners are taxed with a 10x multiplier, but since both the android and the iOS build only take about 15-20 minutes, you will have plenty of free builds each month.
To get going create your workflow definition
In the root of your repository create the folder structure .github/workflows
and create a new file named build.yml
inside it.
Give that pipeline a name. It can be anything you want:
name: "Build application"
Define the triggers of the job
The job has to be triggered by some event. In this case I’ll trigger a build whenever a version tag (a tag matching the v*
pattern) is pushed, but you can use any of these triggers.
on:
push:
tags:
- "v*"
Define the android build job
This will be the job that builds the android application and uploads it to Google Play.
jobs:
build-android:
runs-on: ubuntu-latest
steps:
As for the steps, this is what this job need to do:
Check out the source code
- name: Checkout
uses: actions/checkout@v4
Install NodeJS 20.x
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
Install Java
- name: Install Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
Set up Gradle
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: wrapper
Retrieve code signing certificate (the keystore) from the secrets
- name: Retrieve signing certificate
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo $KEYSTORE_BASE64 | base64 -di > android/app/release.keystore
Retrieve the Google Play credentials JSON from the secrets
- name: Retrieve play credentials
env:
PLAY_CREDENTIALS_BASE64: ${{ secrets.ANDROID_PLAY_CREDENTIALS_BASE64 }}
run: |
echo $PLAY_CREDENTIALS_BASE64 | base64 -di > android/app/play-credentials.json
Install NPM dependencies
- name: Install dependencies
run: |
npm ci
Build the application and create both APK and AAB files
- name: Build
working-directory: android
env:
ANDROID_KEYSTORE: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_CREDENTIALS: play-credentials.json
run: |
./gradlew assembleRelease
./gradlew bundleRelease
Upload the APK to build artifacts. You can use this APK for debug testing
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: android-release
path: android/app/build/outputs/apk/release/app-release.apk
Upload the AAB to the Play Store using Google Play Publisher
- name: Upload to Google Play
working-directory: android
env:
ANDROID_KEYSTORE: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_CREDENTIALS: play-credentials.json
run: |
./gradlew publishBundle
Define to iOS build job
This will be the job that builds the iOS application and uploads it to App Store.
build-ios:
runs-on: macos-latest
steps:
As for the steps, this is what this job need to do:
Check out the source code
- name: Checkout
uses: actions/checkout@v4
Install NodeJS 20.x
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
Generate a temporary keychain password
- name: Generate temporary keychain password
id: keychain-password
run: |
PASSWORD=$(openssl rand -base64 32)
echo "::add-mask::$PASSWORD"
echo "password=$PASSWORD" >> $GITHUB_OUTPUT
To use the code signing certificate we’ve created earlier, we need to import it to the build system. This step creates a password for the keychain we import the key and the certificate into, outputs that password to make it available for other steps and masks the password whenever it would appear in terminal output.
Retrieve and import the code signing certificate
- name: Retrieve signing certificate
id: retrieve-signing-certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.keychain-password.outputs.password }}
run: |
echo $P12_BASE64 | base64 -d > $RUNNER_TEMP/ios-build.p12
CN=$(openssl pkcs12 -in $RUNNER_TEMP/ios-build.p12 -nodes -passin pass:"$P12_PASSWORD" | openssl x509 -noout -subject -nameopt multiline | sed -n 's/ *commonName *= //p')
echo "commonName=$CN" >> $GITHUB_OUTPUT
KEYCHAIN=$RUNNER_TEMP/ios-build.keychain
security create-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN
security set-keychain-settings -lut 21600 $KEYCHAIN
security unlock-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN
security import $RUNNER_TEMP/ios-build.p12 -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN
security list-keychains -d user -s $KEYCHAIN
security find-identity -v -p codesigning
There’s a lot to digest here, so let’s see what this step does:
Retrieve the provisioning profiles
- name: Retrieve provisioning profiles
id: retrieve-provisioning-profiles
env:
PROVISIONING_PROFILES_ZIP_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILES_ZIP_BASE64 }}
run: |
echo $PROVISIONING_PROFILES_ZIP_BASE64 | base64 -d > $RUNNER_TEMP/provisioning_profiles.zip
mkdir -p $RUNNER_TEMP/provisioning_profiles
unzip $RUNNER_TEMP/provisioning_profiles.zip -d $RUNNER_TEMP/provisioning_profiles
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
for PROFILE in $(ls $RUNNER_TEMP/provisioning_profiles/*.mobileprovision); do
PROFILE_NAME="$(basename $PROFILE | awk -F. '{print $1}')"
UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i $PROFILE))
echo "$PROFILE_NAME -> $UUID"
cp $PROFILE ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
echo "$PROFILE_NAME=$UUID" >> $GITHUB_OUTPUT
done
This step does a lot as well, so let’s break it down:
~/Library/MobileDevice/Provisioning Profiles
folder which has to contain the provisioning profiles used during buildInstall NPM dependencies
- name: Install dependencies
run: |
npm ci
Install the required Ruby Gems
- name: Install gems
run: |
bundle install
Install the required CocoaPods
- name: Install CocoaPods
working-directory: ios
run: |
bundle exec pod repo update
bundle exec pod install
Build the application
- name: Build
working-directory: ios
env:
PROVISIONING_PROFILE_APP: ${{ steps.retrieve-provisioning-profiles.outputs.app }}
CODE_SIGN_IDENTITY: ${{ steps.retrieve-signing-certificate.outputs.commonName }}
run: |
set -o pipefail
PACKAGE_VERSION=$(node -e "console.log(require('../package.json').version.split('-')[0])")
xcodebuild archive -workspace <your_app>.xcworkspace -scheme <your_app> -configuration Release -archivePath build/<your_app>.xcarchive CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" PROVISIONING_PROFILE_APP="$PROVISIONING_PROFILE_APP" MARKETING_VERSION="$PACKAGE_VERSION" CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" | bundle exec xcpretty
sed -i.bak "s/@PROVISIONING_PROFILE_APP@/$PROVISIONING_PROFILE_APP/g" ExportOptions.plist
xcodebuild -exportArchive -archivePath build/<your_app>.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build | bundle exec xcpretty
This step contains a lot of complicated commands, so this is what it does:
package.json
Upload the generated IPA to the pipeline artifacts
- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: ios-release
path: ios/build/<your_app>.ipa
Upload application to App Store Connect
- name: Upload to Appstore Connect
env:
APPLEID_USER: ${{ secrets.IOS_APPLEID_USER }}
APPLEID_PASSWORD: ${{ secrets.IOS_APPLEID_PASSWORD }}
run: |
xcrun altool --upload-app -f ios/build/your_app.ipa -u $APPLEID_USER -p $APPLEID_PASSWORD --type ios
Clean up
- name: Cleanup
if: always()
run: |
security delete-keychain $RUNNER_TEMP/ios-build.keychain
rm -rf "~/Library/MobileDevice/Provisioning Profiles"
This step will run whatever happens (even if the pipeline is cancelled) and will delete the keychain and the imported provisioning profiles.
Putting it all together
Your final build pipeline should look something like this:
name: "Build application"
on:
push:
tags:
- "v*"
jobs:
build-android:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Install Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-version: wrapper
- name: Retrieve signing certificate
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
echo $KEYSTORE_BASE64 | base64 -di > android/app/release.keystore
- name: Retrieve play credentials
env:
PLAY_CREDENTIALS_BASE64: ${{ secrets.ANDROID_PLAY_CREDENTIALS_BASE64 }}
run: |
echo $PLAY_CREDENTIALS_BASE64 | base64 -di > android/app/play-credentials.json
- name: Install dependencies
run: |
npm ci
- name: Build
working-directory: android
env:
ANDROID_KEYSTORE: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
run: |
./gradlew assembleRelease
./gradlew bundleRelease
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: android-release
path: android/app/build/outputs/apk/release/app-release.apk
- name: Upload to Google Play
working-directory: android
env:
ANDROID_KEYSTORE: release.keystore
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
PLAY_CREDENTIALS: play-credentials.json
run: |
./gradlew publishBundle
build-ios:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: "20.x"
- name: Generate temporary keychain password
id: keychain-password
run: |
PASSWORD=$(openssl rand -base64 32)
echo "::add-mask::$PASSWORD"
echo "password=$PASSWORD" >> $GITHUB_OUTPUT
- name: Retrieve signing certificate
id: retrieve-signing-certificate
env:
P12_BASE64: ${{ secrets.IOS_P12_BASE64 }}
P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ steps.keychain-password.outputs.password }}
run: |
echo $P12_BASE64 | base64 -d > $RUNNER_TEMP/ios-build.p12
CN=$(openssl pkcs12 -in $RUNNER_TEMP/ios-build.p12 -nodes -passin pass:"$P12_PASSWORD" | openssl x509 -noout -subject -nameopt multiline | sed -n 's/ *commonName *= //p')
echo "commonName=$CN" >> $GITHUB_OUTPUT
KEYCHAIN=$RUNNER_TEMP/ios-build.keychain
security create-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN
security set-keychain-settings -lut 21600 $KEYCHAIN
security unlock-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN
security import $RUNNER_TEMP/ios-build.p12 -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN
security list-keychains -d user -s $KEYCHAIN
security find-identity -v -p codesigning
- name: Retrieve provisioning profiles
id: retrieve-provisioning-profiles
env:
PROVISIONING_PROFILES_ZIP_BASE64: ${{ secrets.IOS_PROVISIONING_PROFILES_ZIP_BASE64 }}
run: |
echo $PROVISIONING_PROFILES_ZIP_BASE64 | base64 -d > $RUNNER_TEMP/provisioning_profiles.zip
mkdir -p $RUNNER_TEMP/provisioning_profiles
unzip $RUNNER_TEMP/provisioning_profiles.zip -d $RUNNER_TEMP/provisioning_profiles
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
for PROFILE in $(ls $RUNNER_TEMP/provisioning_profiles/*.mobileprovision); do
PROFILE_NAME="$(basename $PROFILE | awk -F. '{print $1}')"
UUID=$(/usr/libexec/PlistBuddy -c 'Print :UUID' /dev/stdin <<< $(security cms -D -i $PROFILE))
echo "$PROFILE_NAME -> $UUID"
cp $PROFILE ~/Library/MobileDevice/Provisioning\ Profiles/$UUID.mobileprovision
echo "$PROFILE_NAME=$UUID" >> $GITHUB_OUTPUT
done
- name: Install dependencies
run: |
npm ci
- name: Install gems
run: |
bundle install
- name: Install pods
working-directory: ios
run: |
bundle exec pod repo update
bundle exec pod install
- name: Build
working-directory: ios
env:
PROVISIONING_PROFILE_APP: ${{ steps.retrieve-provisioning-profiles.outputs.app }}
CODE_SIGN_IDENTITY: ${{ steps.retrieve-signing-certificate.outputs.commonName }}
run: |
set -o pipefail
PACKAGE_VERSION=$(node -e "console.log(require('../package.json').version.split('-')[0])")
xcodebuild archive -workspace your_app.xcworkspace -scheme your_app -configuration Release -archivePath build/your_app.xcarchive CODE_SIGN_IDENTITY="$CODE_SIGN_IDENTITY" PROVISIONING_PROFILE_APP="$PROVISIONING_PROFILE_APP" MARKETING_VERSION="$PACKAGE_VERSION" CURRENT_PROJECT_VERSION="$GITHUB_RUN_NUMBER" | bundle exec xcpretty
sed -i.bak "s/@PROVISIONING_PROFILE_APP@/$PROVISIONING_PROFILE_APP/g" ExportOptions.plist
xcodebuild -exportArchive -archivePath build/your_app.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build | bundle exec xcpretty
- name: Upload IPA
uses: actions/upload-artifact@v4
with:
name: ios-release
path: ios/build/your_app.ipa
- name: Upload to Appstore Connect
env:
APPLEID_USER: ${{ secrets.IOS_APPLEID_USER }}
APPLEID_PASSWORD: ${{ secrets.IOS_APPLEID_PASSWORD }}
run: |
xcrun altool --upload-app -f ios/build/your_app.ipa -u $APPLEID_USER -p $APPLEID_PASSWORD --type ios
- name: Cleanup
if: always()
run: |
security delete-keychain $RUNNER_TEMP/ios-build.keychain
rm -rf "~/Library/MobileDevice/Provisioning Profiles"
As you could see, we’re using the secrets feature of GitHub actions to store some information required for the pipeline to run.
You can set these variables if you open your repo, go to Settings, then Secrets and variables, then Actions.
You need to add the following secrets:
ANDROID_KEYSTORE_BASE64
You need to set this one to the base64 encoded keystore file you’ve created for Android.
You can get the value using the following command:
openssl base64 -in android-upload-key.keystore -A
ANDROID_KEYSTORE_PASSWORD
Set it to the password generated when creating the keystore for Android.
ANDROID_PLAY_CREDENTIALS_BASE64
Set it to the credentials JSON file used by Google Play Publisher, encoded as base64.
To create this file you have to use both the Play Console and the GCloud console. Follow the instructions from here.
After you got the JSON file, encode it as base64 like this:
openssl base64 -in credentials.json -A
IOS_P12_BASE64
Set this to the base64 encoded PKCS#12 archive containing the code signing certificate for iOS.
To do the encoding, use the following command:
openssl base64 -in ios-signing-cert.p12 -A
IOS_P12_PASSWORD
Set this to the password used to secure the PKCS#12 archive.
IOS_PROVISIONING_PROFILES_ZIP_BASE64
Set this to the base64 encoded zip file containing the provisioning profiles.
To do the encoding, use the following command:
openssl base64 -in provisioning-profiles.zip -A
IOS_APPLEID_USER
Set this to the username (email) of the Apple ID account which has permissions to upload application binaries to App Store Connect.
IOS_APPLEID_PASSWORD
Set this to an app-specific password for that user. You can create an app-specific password following this guide.
That’s it! You’ve just created a complete CI/CD pipeline for your React Native application. You can now push a version tag to your repository and the pipeline will automatically build and upload the application to Google Play and App Store Connect.
This pipeline can be further extended to include tests, static code analysis or changelog generation.
Feel free to reach out to us at [email protected] or comment below.
You need someone to implement this pipeline and more (like tests, static analysis, changelog generation, etc.) for you? Check out our DevOps services.
Comments