The complete guide to build and deploy your React Native application using GitHub Actions

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.

Prerequisites

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.

Configuring the application

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:

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:

We’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'

Create the code signing certificates

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.

Create the actual GitHub Actions workflow

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.

  1. 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"
    
  2. 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*"
    
  3. 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:

    1. Check out the source code

      - name: Checkout
        uses: actions/checkout@v4
      
    2. Install NodeJS 20.x

      - name: Install NodeJS
          uses: actions/setup-node@v4
          with:
            node-version: "20.x"
      
    3. Install Java

      - name: Install Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      
    4. Set up Gradle

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3
        with:
          gradle-version: wrapper
      
    5. 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    
      
    6. 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    
      
    7. Install NPM dependencies

      - name: Install dependencies
        run: |
          npm ci    
      
    8. 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    
      
    9. 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
      
    10. 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    
      
  4. 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:

    1. Check out the source code

      - name: Checkout
        uses: actions/checkout@v4
      
    2. Install NodeJS 20.x

      - name: Install NodeJS
        uses: actions/setup-node@v4
        with:
          node-version: "20.x"
      
    3. 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.

    4. 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:

      • Decodes the base64 encoded PKCS#12 archive from the secrets
      • Extracts the common name of the signing entity from the certificate from this archive
      • Outputs that common name to the output of the step
      • Creates a new keychain with the temporary keychain password
      • Sets a generous timeout for that keychain
      • Unlocks the keychain
      • Imports the PKCS#12 archive into this keychain
      • Adds this keychain to the search list
      • Finally it verifies if the system includes a code signing identity to fail early if the import didn’t go as planned
    5. 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:

      • Decodes the base64 encoded ZIP archive containing the provisioning profiles from the secrets
      • Creates a temporary folder where we extract the ZIP to
      • Creates the ~/Library/MobileDevice/Provisioning Profiles folder which has to contain the provisioning profiles used during build
      • Loops through each profile and does the following:
        • Extracts the UUID of the profile from the file
        • Copies the profile to the required target
        • Outputs the UUID both to the console for debugging and to the step outputs for future use
    6. Install NPM dependencies

      - name: Install dependencies
        run: |
          npm ci    
      
    7. Install the required Ruby Gems

      - name: Install gems
        run: |
          bundle install    
      
    8. Install the required CocoaPods

      - name: Install CocoaPods
        working-directory: ios
        run: |
          bundle exec pod repo update
          bundle exec pod install    
      
    9. 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:

      • Sets the pipefail option, so if the first command fails, the whole pipe command fails. This is required because we pipe the xcodebuild output through xcpretty to make it look better
      • Extracts the package version from package.json
      • Build the application
      • Modifies the export options plist file to use the correct provisioning profile
      • Exports the build to an IPA file
    10. 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
      
    11. 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    
      
    12. 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.

  5. 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"          
    

Configure the GitHub Actions Secrets

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:

Conclusion

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.

Do you need more help?

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