React Native/Expo Azure Pipelines
Nikola Gorgiev
Software Developer | Flutter Enthusiast | React | React Native | Electron | JavaScript | TypeScript
Some time ago, while I was working on another project with React Native (CLI not Expo), I have written few articles regarding writing Azure Pipelines. This time I have decided to write another article on the same topic, but this time for Expo. The reason why I have decided to share my struggle is so you don't have to go to through the same issues I have faced, and save some valuable time, and believe me, ChatGPT wont help you with all the issues that you may face, because it did not help to me either (it did on some of the issues but not on all of them).
Apple Hell
First lest start with the Apple Hell again. One issues that i still have problems every time, is generating the p12 certificates, and few things I have learned this time, are the following ones:
when you open the Keychain Access, make sure that in the Default Keychains you have the login tab selected.
when selecting the installed certificates from which you want to generate the p12, make sure that you expand the certificate and select the inside item too. Also don't select more than two items, or one certificate and only its child's when exporting the p12, or for example don't select the Development and the Distribution certificate to export single p12 from those, do two separate p12 one for Development and another for Distribution.
Also another issue that i had when dealing with this Apple Hell was finding the correct certificate that i need to select in order to generate the p12, because in my case, and probably in most of yours, you will have a lot of certificates installed so its hard to find the correct one. To find the correct certificate, here are few advices:
The Pipeline
Now lets take a look at the actual pipeline. The first thing that differs from RN CLI and Expo, is that in expo we don't have the iOS and android folders exposed, so we want to manually build our app without the EAS servers that for small teams the Free tier should be good enough, but if you are working on bigger project with bigger team making a lot of builds, the waiting in the Free tier is becoming a problem. So the first step is to prebuilt the app with the expo prebuilt command.
Another problem when we are going away from EAS cloud builds, is if we manually build the app, we don't have the ease of using the generated QR codes to directly install the app on our devices, and to solve this, i have found a nice website, called AppSend.dev, which also provides API to upload our APK/IPA and generate QR code to install the app using it, but also give you option to give the endpoint an email list to which you want the link to the QR code to be send.
With this we have two major issues sorted out. Now lets take a look at the variable groups and branches i have in my setup. In my case I have three branches that trigger the pipeline automatically and each of those branches represents different environment:
Each variable group has the same env variables:
Now lets look at the actual pipeline yaml code:
name: $(Rev:r)
trigger:
branches:
include:
- testing
- qa
- release
variables:
- ${{ if eq(variables['Build.SourceBranchName'], 'testing') }}:
- group: mobile
- ${{ if eq(variables['Build.SourceBranchName'], 'qa') }}:
- group: mobile-qa
- ${{ if eq(variables['Build.SourceBranchName'], 'release') }}:
- group: mobile-release
- ${{ if and(ne(variables['Build.SourceBranchName'], 'testing'), ne(variables['Build.SourceBranchName'], 'qa'), ne(variables['Build.SourceBranchName'], 'release')) }}:
- group: mobile
pool:
vmImage: 'macos-14'
stages:
- stage: Build
jobs:
- job: Setup
steps:
- checkout: self
persistCredentials: true
clean: true
fetchDepth: 0
- script: |
echo "Branch name: $(Build.SourceBranchName)"
displayName: 'Print Branch Name'
- script: |
df -h
availableSpace=$(df / | awk 'NR==2 { print $4 }')
echo "Available space: $availableSpace"
availableSpaceGB=$(echo $availableSpace | grep -o -E '[0-9]+')
if [ "$availableSpaceGB" -lt 5 ]; then
echo "Error: Not enough disk space available."
exit 1
fi
displayName: 'Check Disk Space'
- script: |
echo "##vso[task.setvariable variable=EXPO_PUBLIC_BUILD_NUMBER]$(Build.BuildNumber)"
displayName: 'Set Build Number'
- script: |
git fetch --tags
lastTag=$(git tag | grep 'build-' | sort -t '-' -k 2 -n | tail -1)
changelog=$(git log $lastTag..HEAD --pretty=format:"- %s" | grep -v "Merged PR")
printf "Build Number: %s\n\n%s" "$(EXPO_PUBLIC_BUILD_NUMBER)" "$changelog" > $(Build.ArtifactStagingDirectory)/changelog.txt
cat $(Build.ArtifactStagingDirectory)/changelog.txt
displayName: 'Generate and Store Changelog'
- task: PublishBuildArtifacts@1
displayName: 'Publish Changelog to artifacts'
inputs:
PathtoPublish: '$(Build.ArtifactStagingDirectory)'
ArtifactName: 'changelog'
ArtifactType: 'Container'
TargetPath: '$(Build.ArtifactStagingDirectory)/changelog.txt'
- job: Android
dependsOn: Setup
timeoutInMinutes: 90
steps:
- checkout: self
displayName: 'Checkout Repository'
persistCredentials: true
- script: |
echo "##vso[task.setvariable variable=EXPO_PUBLIC_BUILD_NUMBER]$(Build.BuildNumber)"
displayName: 'Set Build Number'
- task: NodeTool@0
displayName: 'Install Node'
inputs:
versionSpec: '18.19.0'
- script: |
# Print environment variable values for verification
echo "iosBundleIdentifier from Azure: $(iosBundleIdentifier)"
echo "androidPackageName from Azure: $(androidPackageName)"
# Use environment variables from Azure to set bundle ID and package name
echo "Updating bundleId and package name based on environment variables"
# For app.json or app.config.js
sed -i '' "s/\"bundleIdentifier\": \".*\"/\"bundleIdentifier\": \"$(iosBundleIdentifier)\"/" app.json
sed -i '' "s/\"package\": \".*\"/\"package\": \"$(androidPackageName)\"/" app.json
# Print the updated values in app.json for verification
echo "Updated app.json contents:"
cat app.json
displayName: 'Update bundleId, package name and verify values'
- script: yarn install
displayName: 'Install dependencies'
- script: npx expo install
displayName: 'Install expo dependencies'
- script: yarn run prebuild:clean
displayName: 'Prebuilding the project [generate ios/android projects]'
- script: |
# Disable autocommit on version bump
yarn config set version-sign-git-tag false
yarn config set version-git-tag false
yarn config set version-commit-hooks false
# Checkout branch where the build is triggered
git checkout $(Build.SourceBranchName)
# Extract existing version of package.json
oldVer=$(jq -r ".version" package.json)
# Bump version
yarn version --patch
# Add bumped version to staging
git add *
# Extract new version of package.json
newVer=$(jq -r ".version" package.json)
# Set environment variables
echo "##vso[task.setvariable variable=OLD_VERSION]$oldVer"
echo "##vso[task.setvariable variable=NEW_VERSION]$newVer"
displayName: 'Bump version and set variables'
- task: Gradle@2
displayName: 'Build APK'
inputs:
gradleWrapperFile: 'android/gradlew'
workingDirectory: 'android/'
options: '-PversionName=$(NEW_VERSION) -PversionCode=$(Build.BuildId)'
tasks: 'assembleRelease'
publishJUnitResults: false
javaHomeOption: 'JDKVersion'
jdkVersionOption: '1.17'
gradleOptions: '-Xmx3072m'
sonarQubeRunAnalysis: false
- task: AndroidSigning@3
displayName: 'Sign APK'
inputs:
apkFiles: 'android/app/build/outputs/apk/release/*.apk'
apksignerKeystoreFile: '$(keystoreFilename)'
apksignerKeystorePassword: '$(releaseKeystorePassword)'
apksignerKeystoreAlias: '$(releaseKeystorePassword)'
apksignerKeyPassword: '$(releaseKeystorePassword)'
zipalign: true
- task: PublishBuildArtifacts@1
displayName: 'Publish APK to artifacts'
inputs:
PathtoPublish: 'android/app/build/outputs/apk/release'
ArtifactName: 'android'
publishLocation: 'Container'
- task: CopyFiles@2
displayName: 'Copy APK'
inputs:
contents: '**/*.apk'
targetFolder: '$(build.artifactStagingDirectory)'
overWrite: true
flattenFolders: true
- task: DownloadBuildArtifacts@0
displayName: 'Download artifacts'
inputs:
buildType: current
downloadType: specific
artifactName: android-test
downloadPath: '$(build.artifactStagingDirectory)'
- script: |
curl https://api.appsend.dev/v1/uploads/ \
-F file=@$(build.ArtifactStagingDirectory)/app-release.apk \
-F message="Latest Android Version (build $(EXPO_PUBLIC_BUILD_NUMBER)) of APP_NAME (files avaliable for the next two days)" \
-F testers="$(testersEmailList)"
displayName: 'Upload APK to AppSend.dev'
condition: or(eq(variables['Build.SourceBranchName'], 'testing'), eq(variables['Build.SourceBranchName'], 'qa'), eq(variables['Build.SourceBranchName'], 'release'))
- job: iOS
dependsOn: Setup
timeoutInMinutes: 90
steps:
- checkout: self
displayName: 'Checkout Repository'
persistCredentials: true
- script: |
echo "##vso[task.setvariable variable=EXPO_PUBLIC_BUILD_NUMBER]$(Build.BuildNumber)"
displayName: 'Set Build Number'
- task: InstallAppleCertificate@2
displayName: 'Install Apple Certificate'
inputs:
certSecureFile: '$(p12DistributionFilename)'
certPwd: '$(p12CertificatePassword)'
keychain: 'temp'
deleteCert: true
- task: InstallAppleCertificate@2
displayName: Install Apple Development Certificate
inputs:
certSecureFile: '$(p12DevelopmentFilename)'
certPwd: '$(p12DevelopmentPassword)'
keychain: 'temp'
deleteCert: true
- task: InstallAppleProvisioningProfile@1
displayName: 'Install Apple Provisioning Profile'
inputs:
provisioningProfileLocation: 'secureFiles'
provProfileSecureFile: '$(provisionProfileFilename)'
removeProfile: true
- task: NodeTool@0
displayName: 'Install Node'
inputs:
versionSpec: '18.19.0'
- script: |
# Print environment variable values for verification
echo "iosBundleIdentifier from Azure: $(iosBundleIdentifier)"
echo "androidPackageName from Azure: $(androidPackageName)"
# Use environment variables from Azure to set bundle ID and package name
echo "Updating bundleId and package name based on environment variables"
# For app.json or app.config.js
sed -i '' "s/\"bundleIdentifier\": \".*\"/\"bundleIdentifier\": \"$(iosBundleIdentifier)\"/" app.json
sed -i '' "s/\"package\": \".*\"/\"package\": \"$(androidPackageName)\"/" app.json
# Print the updated values in app.json for verification
echo "Updated app.json contents:"
cat app.json
displayName: 'Update bundleId, package name and verify values'
- script: yarn install
displayName: 'Install dependencies'
- script: npx expo install
displayName: 'Install expo dependencies'
- script: yarn run prebuild:clean
displayName: 'Prebuilding the project [generate ios/android projects]'
- script: |
# Disable autocommit on version bump
yarn config set version-sign-git-tag false
yarn config set version-git-tag false
yarn config set version-commit-hooks false
# Checkout branch where the build is triggered
git checkout $(Build.SourceBranchName)
# Extract existing version of package.json
oldVer=$(jq -r ".version" package.json)
# Bump version
yarn version --patch
# Add bumped version to staging
git add *
# Extract new version of package.json
newVer=$(jq -r ".version" package.json)
# Set environment variables
echo "##vso[task.setvariable variable=OLD_VERSION]$oldVer"
echo "##vso[task.setvariable variable=NEW_VERSION]$newVer"
displayName: 'Bump version and set variables'
- task: ios-bundle-version@1
displayName: 'Bump iOS version'
inputs:
sourcePath: 'ios/APP_NAME/Info.plist'
versionCodeOption: 'buildid'
versionCode: '$(Build.BuildId)'
versionName: '$(NEW_VERSION)'
printFile: false
- task: Xcode@5
displayName: 'Build IPA'
inputs:
actions: 'clean build'
configuration: 'Release'
teamId: '$(appleTeamId)'
sdk: 'iphoneos'
xcWorkspacePath: '**/APP_NAME.xcworkspace'
scheme: 'APP_NAME'
packageApp: true
signingOption: manual
exportPath: 'output'
signingIdentity: '$(APPLE_CERTIFICATE_SIGNING_IDENTITY)'
provisioningProfileUuid: '$(APPLE_PROV_PROFILE_UUID)'
args: '-verbose'
- task: CopyFiles@2
displayName: 'Copy IPA'
inputs:
contents: '**/*.ipa'
targetFolder: '$(build.artifactStagingDirectory)'
overWrite: true
flattenFolders: true
- task: PublishBuildArtifacts@1
displayName: 'Publish IPA to artifacts'
inputs:
PathtoPublish: '$(build.artifactStagingDirectory)'
ArtifactName: 'ios'
publishLocation: 'Container'
- task: DownloadBuildArtifacts@0
displayName: 'Download artifacts'
inputs:
buildType: current
downloadType: specific
artifactName: ios-test
downloadPath: '$(build.artifactStagingDirectory)'
- script: |
curl https://api.appsend.dev/v1/uploads/ \
-F file=@$(build.ArtifactStagingDirectory)/ios/APP_NAME.ipa \
-F message="Latest iOS Version (build $(EXPO_PUBLIC_BUILD_NUMBER)) of APP_NAME (files avaliable for the next two days)" \
-F testers="$(testersEmailList)"
displayName: 'Upload IPA to AppSend.dev'
condition: or(eq(variables['Build.SourceBranchName'], 'testing'), eq(variables['Build.SourceBranchName'], 'qa'), eq(variables['Build.SourceBranchName'], 'release'))
- script: |
# Tag the current commit with a unique build tag
git tag build-$(EXPO_PUBLIC_BUILD_NUMBER)
git push origin build-$(EXPO_PUBLIC_BUILD_NUMBER)
displayName: 'Tag Current Build'
condition: succeeded()
Notes:
To sum it up here is the flow of the pipeline. Basically I have 3 jobs, first one is Setup then Android and the last one is iOS build job. Here is the flow of each job, starting with the Setup one:
Android job:
IOS job: