Welcome back to the CI/CD realm! I’m not exaggerating when I say that GitHub Actions makes setting up React Native CI/CD a cakewalk. I hope you had a chance to read Part 1 of this blog series: Github actions and how to set up manual triggers! If not, do check it out; it will help you to understand the initial setup.

Our 3-phase DevOps implementation improved the CI/CD engine and streamlined deployment for a software company. Know how!
In this blog, I’ll mainly focus on steps related to iOS like Ruby setup, pod install, build, and upload. So, let’s get started!
You can follow these 7 steps to set up React Native CI/CD using GitHub Actions for iOS:
1. Set up Ruby using ruby/setup-ruby@v1 (iOS only)
2. Restore Pods cache using actions/cache@v3
3. Install Pods
4. Bump iOS version
5. Build an iOS app
6. Configure iOS project on Xcode
7. Upload the app to TestFlight
How to Set up Ruby using ruby/setup-ruby@v1 (iOS only)?
- name: ruby setup uses: ruby/setup-ruby@v1 with: ruby-version: 2.6 bundler-cache: true env: ImageOS: macos1015
If the virtual environment is extremely similar to the ones used by GitHub runners, this action will work with self-hosted runners. Notably:
- Use the exact same version.
- Set the environment variable ImageOS on the runner (for example, ubuntu18/macos1015/win19) to the appropriate value on GitHub-hosted runners. To determine the operating system and version, this is required.
- The libssl version must be the same.
- Ensure that libgmp and libyaml-0 are installed on the operating system.
- The runner user must have written access to the default tool cache directory (/opt/hostedtoolcache on Linux, /Users/runner/hostedtoolcache on macOS, and C:/hostedtoolcache/windows on Windows). Since the install path is permanently included in the Ruby builds and cannot be changed, this is required.
- The runner user must have written access to /home/runner.
Now, allow me to explain how you can restore Pods cache using actions/cache@v3.
How to Restore Pods cache using actions/cache@v3 ?
- name: Restore Pods cache uses: actions/cache@v3 with: path: | ios/Pods ~/Library/Caches/CocoaPods ~/.cocoapods key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }} restore-keys: | ${{ runner.os }}-pods-
Now it’s time to install the Pods!
How to Install Pods?
- name: Install Pods run: pod install --repo-update && cd ..
Here’s how you can bump the iOS version.
How to Bump iOS version?
- name: bump version uses: abhishek219tiwari/[email protected] with: marketingVersion: ${{ github.event.inputs.version }} project-path: ios/Nitor/Config/Info-plist/Nitor-Dev-info.plist
It’s not necessary to use this action, this is under development if agvTool is working fine for you. You can go with other options as well, such as the iOS bump version.
Let’s look at how you can build an iOS app.
How to Build an iOS app?
- name: Build IOS App NitorPro / staging if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: NitorPro - Staging
Now that you have the app in place, read on to know how you can configure your iOS project on Xcode.
How to Configure iOS project on Xcode?
- Double-clicking the file ios/XXXX.xcworkspace (not the.xcodeproj file) will launch your project in Xcode.
- Choose the project target, singing and capabilities, then release.
- Uncheck Automatically manage singing.
- Import the downloaded profile into Provisioning Profile.
- If the certificate connected to the profile is already installed on your device, you should see it there as well. The team name should also instantly display.
We utilize yukiarrr/[email protected] to create the app and export the IPA for TestFlight submission.
The following variables must be added as GitHub Secrets for the build action:
- IOS MOBILE PROVISION BASE64: Provisioning profile file in Base64 encoding.
Use the following script to build base64 encoded format after downloading it from your Apple developer portal.
openssl base64 < YOUR_Profile.mobileprovision | tr -d ‘\n’ | tee my-profile.base64.txt
- IOS P12 BASE64:.p12 file with base64 encoding (key + cert)
Once the certificate has been installed on your Mac, launch the Keychain Access app, choose ‘My Certificates’ from the menu, and then find the downloaded certificate.
To view the matching private key, expand the certificate. Next, choose ‘Export 2 items…’ from the context menu when you right-click on the certificate and private key.
Choose a location on your computer to save the file as a.p12 and give it a secure password (IOS CERTIFICATE PASSWORD).
For the .p12 file, create a base64 using
openssl base64 < cert.p12 | tr -d ‘\n’ | tee cert.base64.txt
- IOS_TEAM_ID: Your apple team id https://developer.apple.com/account/#!/membership/
- IOS CERTIFICATE PASSWORD: P2 certificate creation password.
The final step is to use upload-TestFlight-build to upload the created IPA to TestFlight.
- The issuer ID is something like 598542-36fe-1a63-e073-0824d0166672a; go to Users and Access > API Keys.
- The AppStore Connect API Key ID is APPSTORE API KEY ID.
- APPSTORE API PRIVATE KEY: The AppStore Connect API’s private key in PKCS8 format. The information in the created file AUTH Key xxxxxx.p8
Note: As we are using a personal Mac system as self-hosted, we need to download Apple Worldwide Developer Relations Certificate Authority.
Now it’s time to see how you can upload your app to TestFlight.
How to Upload the app to TestFlight?
#Upload app to TestFlight For NitorPro / staging - name: Upload app to TestFlight NitorPro / staging if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
If you get stuck in any of the steps, you can get some help from here, I have put my .yml.
name: iOS Build Deployment on: workflow_dispatch: inputs: target: description: "Target" required: true default: "Nitor" type: choice options: - Nitor - NitorPro flavor: description: "Environment" required: true default: "staging" type: choice options: - staging - development - production version: description: new app version x.x.x. required: true jobs: ios-build: name: IOS Production Build runs-on: self-hosted defaults: run: working-directory: ios steps: - name: Check out Git repository uses: actions/checkout@v2 with: persist-credentials: false # make it false if you are going to clone private repositories in yarn - name: Set up our node configuration uses: actions/setup-node@v1 with: node-version: 16.x - name: Decode .env.Nitor.staging # decode .env.Nitor.staging and generate .env.Nitor.staging file if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'staging'}} id: decode_Nitor_staging uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITOR_STAGING}} - name: Decode .env.Nitor.development # decode .env.Nitor.development and generate .env.Nitor.development file if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'development'}} id: decode_Nitor_development uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITOR_DEVELOPMENT}} - name: Decode .env.Nitor.production # decode .env.Nitor.production and generate .env.Nitor.production file if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'production'}} id: decode_Nitor_production uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITOR_PRODUCTION}} - name: Decode .env.NitorPro.staging # decode .env.NitorPro.stagingand generate .env.NitorPro.staging file if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging'}} id: decode_NitorPro_staging uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITORPRO_STAGING}} - name: Decode .env.NitorPro.development # decode .env.NitorPro.development and generate .env.NitorPro.development file if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'development'}} id: decode_NitorPro_development uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITORPRO_DEVELOPMENT}} - name: Decode .env.NitorPro.production # decode .env.NitorPro.production and generate .env.NitorPro.production file if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'production'}} id: decode_NitorPro_production uses: timheuer/base64-to-file@v1 with: fileName: ".env" encodedString: ${{ secrets.NITORPRO_PRODUCTION}} - name: "move Nitor_staging" if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'staging'}} run: | mv ${{steps.decode_Nitor_staging.outputs.filePath}} ./ pwd cat .env - name: "move Nitor_development" if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'development'}} run: | mv ${{steps.decode_Nitor_development.outputs.filePath}} ./ pwd cat .env - name: "move Nitor_production" if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'production'}} run: | mv ${{steps.decode_Nitor_production.outputs.filePath}} ./ pwd cat .env - name: "move NitorPro_staging" if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging'}} run: | mv ${{steps.decode_NitorPro_staging.outputs.filePath}} ./ pwd cat .env - name: "move NitorPro_development" if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'development'}} run: | mv ${{steps.decode_NitorPro_development.outputs.filePath}} ./ pwd cat .env - name: "move NitorPro_production" if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'production'}} run: | mv ${{steps.decode_NitorPro_production.outputs.filePath}} ./ pwd cat .env - name: git config with PAT_GITHUB run: git config --global url.https://${{ secrets.PAT_GITHUB }}@github.com/.insteadOf https://github.com - run: | echo "github.event.inputs.version: ${{ github.event.inputs.version }}" - name : ruby setup uses: ruby/setup-ruby@v1 with: ruby-version: 2.6 bundler-cache: true env: ImageOS: macos1015 LANG: en_US.UTF-8 - name: bump version uses: abhishek219tiwari/[email protected] with: marketingVersion: ${{ github.event.inputs.version }} project-path: ios/Nitor/Config/Info-plist/Nitor-Dev-info.plist - name: Get yarn cache directory path id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - name: Restore node_modules from cache uses: actions/cache@v3 id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: yarn install if: | steps.cache-yarn-cache.outputs.cache-hit != 'true' || steps.cache-node-modules.outputs.cache-hit != 'true' run: | yarn install --network-concurrency 1 - name: Restore Pods cache uses: actions/cache@v3 with: path: | ios/Pods ~/Library/Caches/CocoaPods ~/.cocoapods key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }} restore-keys: | ${{ runner.os }}-pods- - name: Install Pods run: pod install --repo-update && cd .. - name: check .env run: cat .env # Build IOS App Nitor / staging - name: Build IOS App Nitor / staging if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'staging' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: Nitor - Staging # Build IOS App Nitor / development - name: Build IOS App Nitor / development if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'development' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: Nitor - Dev # Build IOS App Nitor / production - name: Build IOS App Nitor / production if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'production' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: Nitor # Build IOS App NitorPro / staging - name: Build IOS App NitorPro / staging if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: NitorPro - Staging # Build IOS App NitorPro / development - name: Build IOS App NitorPro / development if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'development' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: NitorPro - Dev # Build IOS App NitorPro / production - name: Build IOS App NitorPro / production if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'production' }} uses: yukiarrr/[email protected] with: project-path: ios/Nitor.xcodeproj p12-base64: ${{ secrets.IOS_P12_BASE64 }} mobileprovision-base64: ${{ secrets.IOS_MOBILE_PROVISION_BASE64 }} code-signing-identity: "iPhone Distribution" team-id: ${{ secrets.IOS_TEAM_ID }} certificate-password: ${{ secrets.IOS_CERTIFICATE_PASSWORD }} workspace-path: ios/Nitor.xcworkspace scheme: NitorPro #Upload app to TestFlight For Nitor / staging - name: Upload app to TestFlight Nitor / staging if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'staging' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} #Upload app to TestFlight For Nitor / development - name: Upload app to TestFlight Nitor / development if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'development' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} #Upload app to TestFlight For Nitor / production - name: Upload app to TestFlight Nitor / production if: ${{ github.event.inputs.target == 'Nitor' && github.event.inputs.flavor == 'production' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} #Upload app to TestFlight For NitorPro / staging - name: Upload app to TestFlight NitorPro / staging if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'staging' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} #Upload app to TestFlight For NitorPro / development - name: Upload app to TestFlight NitorPro / development if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'development' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} #Upload app to TestFlight For NitorPro / production - name: Upload app to TestFlight NitorPro / production if: ${{ github.event.inputs.target == 'NitorPro' && github.event.inputs.flavor == 'production' }} uses: apple-actions/upload-testflight-build@v1 with: app-path: "output.ipa" issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }}
That’s all for today’s blog! You can save this blog as a pocket guide to the iOS setup of CI/CD with GitHub Actions! Stay tuned for Part 3 in this series which will walk you through setting up CI/CD in the world of Android! Meanwhile, write to us with your thoughts about Part 2 and visit us at Nitor Infotech to learn about what we do.