React Native/Expo Azure Pipelines

React Native/Expo Azure Pipelines

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:

  • first you will notice that in the development certificates you will have Development in the name, and in the Distribution, there is going to be Distribution in the name
  • then if you double click on the certificate inside the Keychain Access under the Organizational Unit you will find your Team id so you can know that is the correct certificate
  • Another thing you can compare is the expiration dates

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:

  • testing - the testing environment uses the mobile variable group in AzureDevops
  • qa - the qa environment uses the mobile-qa variable group in AzureDevops
  • release - the release environment uses the mobile-release variable group in AzureDevops

Each variable group has the same env variables:

  • androidPackageName - the bundle identifier for the android app (ex. com.example.app)
  • appleTeamId - the Apple Developer Team ID
  • iosBundleIdentifier - the bundle identifier for the ios app (ex. com.example.app)
  • keystoreFilename - the filename of the android keystore file that is uploaded to AzureDevops secret files
  • p12CertificatePassword - the password to p12 distribution certificate
  • p12DevelopmentFilename - the filename of the p12 development certificate that is uploaded to AzureDevops secret files
  • p12DevelopmentPassword - the password to p12 development certificate
  • p12DistributionFilename - the filename of the p12 distribution certificate that is uploaded to AzureDevops secret files
  • provisionProfileFilename - the filename of the provision profile that is uploaded to AzureDevops secret files
  • releaseKeystorePassword - keystore password
  • testersEmailList - the email list to who AppSend will send the email with the QR code to install the app (ex. [email protected], [email protected], [email protected] )

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:

  • instead of APP_NAME you have to put your actual app name, or do some debugging to see what value it should be put instead of APP_NAME, because this string depends on the displayName and name values inside the app.json
  • also at the start of the build I am constructing the build number for the app, and its always going to be the Azure Devops build number
  • in the artifacts I am also generating a changelog.txt file, which is consisted of all the commit messages excluding the ones that contains "Merge" in the commit message

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:

  1. Set the build number
  2. Set the branches that automatically trigger the pipeline whenever we push something to those branches
  3. select the variable group
  4. check if the build machine has free space, if not stop the pipeline
  5. generate the changelog.txt. Basically after every build i put a tag on git with the build number, and changelog contains all commit messages from the last build tag to the current git head

Android job:

  1. Install Node
  2. Update the package and bundleIdentifier in the app.json with the values from Azure variable group
  3. install npm dependencies (I use yarn in my case)
  4. install expo dependencies
  5. prebuild the project
  6. build APK
  7. sign APK
  8. publish APK to the artifacts
  9. upload the app to AppSend.dev

IOS job:

  1. install distribution certificate
  2. install development certificate
  3. install provision profiles
  4. Install Node
  5. Update the package and bundleIdentifier in the app.json with the values from Azure variable group
  6. install npm dependencies (I use yarn in my case)
  7. install expo dependencies
  8. prebuild the project
  9. set new version to the app
  10. build IPA
  11. publish IPA to the artifacts
  12. upload the app to AppSend.dev
  13. set new tag to git with the build number

要查看或添加评论,请登录