Thursday, October 8, 2020

Ionic Capacitor CI/CD using new yaml pipeline with multiple stages

The Ionic Capacitor is a new framework to build cross-platform apps. In this article, we will learn how to set up an Azure DevOps pipeline to build and deploy to AppCenter to distribute to test users.

For simplicity, I am going to build only the android project in this article. You can always add more jobs to run in parallel to build the other environments. Also, I am assuming that you have some experience build Azure pipelines and know about defining variables in the pipeline and how to store secrets in Secure Files and Variables. Please let me know in the comments if there anything specific about the pipeline you don't understand,

I started with creating an empty yaml pipeline and here you have different options to choose your repo from. I have my repo on Azure reports Git and will select that and choose my repo from the next screen. 

 

New pipeline

Once you select your repo you will get some recommendations in the Configure 
Configure your pipeline


I picked up the starter pipeline to get the basic pipeline in place, which will checkout the branch based on the trigger branch, which by default will be master.

Once you select the Starter pipeline you will get something like this.


# Starter pipeline
# Start with a minimal pipeline that you can customize to build and deploy your code.
# Add steps that build, run tests, deploy, and more:
# https://aka.ms/yaml

trigger:
master

pool:
  vmImage'ubuntu-latest'

steps:
scriptecho Hello, world!
  displayName'Run a one-line script'

script: |
    echo Add other tasks to build, test, and deploy your project.
    echo See https://aka.ms/yaml
  displayName'Run a multi-line script'

we will start to make changes to the pipeline.

If you are not aware of the new pipeline yaml CI/Cd, I would suggest giving YAML schema reference a
read also.

trigger


The trigger defines the point when the pipeline will start running. There are so many things with trigger option
follow CI triggers to know more about it.

In my case, I have 2 branches one for UAT and one for release to production which is master. So I have included
these 2 branches to be the trigger point. Any commit made to any of these branches will trigger the pipeline run.
Here is what my configuration looks like

trigger:
 branches:
  include:
    - master
    - uat

variables


List the variables to be used in the pipeline. I have one variable which is the name of the build image that
I am going to use it for my builds. Check Define variables to know more.

variables:
 vmImageName'macos-latest'

Different Environments


DevOps Changes


For each deployment stage, you will have to create an environment.

Environments



Environments lets to create approvals and checks

Approvals and Checks

I have Approvals set for UAT branch and also have a branch control set for UA to allow only uat and master branhes
are allowed to be deployed to this environment.


This is how the view of the pipeline run looks like


This is all you need to do to build and deploy your Capacitor app.

Configuration changes

There are different configuration settings for different environments. As mentioned earlier there are 3 different environemnt
I had configured for the pipeline. The ionic capacitor has different environment settings file we can use for different configurations.


if you are adding more environment to the ionic capacitor. You may want to add the configurations to the angular.json file

"staging": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.staging.ts"
}
]
},
"uat": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.uat.ts"
}
]
},

Settings the parameters

you set the parameters in the environment files. e.g. if you want to change the api url for different environments.

export const environment = { production: true, apiBaseUrl: '#{prod_api_baseurl}#' };

in your pipeline set the variable with that name with the value for production api url.

For replacing the tokens I am using replace-token-task plugin.

Here is the configuration for the task

taskreplacetokens@3
            inputs:
              rootDirectory'./src/environments/'
              targetFiles'**/*.staging.ts'
              encoding'auto'
              writeBOMtrue
              actionOnMissing'warn'
              keepTokenfalse
              tokenPrefix'#{'
              tokenSuffix'}#'
            displayName"Replace environment variables"

Build and Deploy

The pipeline can get large and you should try to split the pipeline in templates. I used the following structure for pipeline


azure-pipelines.yml: This is the file that will be calling the other templates. 

# name is used for build version
name$(build_version)

trigger:
  branches:
    include:
      - master

  paths:
    include:
      - azure-pipeline/*

variables:
  vmImageName'macos-latest'
  buildNumber'$(Build.BuildNumber)'
  stagingArtifact'stagingapks'
  stagingiOSArtifact'iOSTodoStaging'
  isMain$[eq(variables['Build.SourceBranch'], 'refs/heads/master')]

stages:
  - stageBuild
    displayNameBuild Staging
    jobs:
      - jobStagingBuild_Android
        displayNameBuild Staging app
        pool:
          vmImage$(vmImageName)
        steps:
          - taskreplacetokens@3
            inputs:
              rootDirectory'./src/environments/'
              targetFiles'**/*.staging.ts'
              encoding'auto'
              writeBOMtrue
              actionOnMissing'warn'
              keepTokenfalse
              tokenPrefix'#{'
              tokenSuffix'}#'
            displayName"Replace environment variables"

          - templateandroid-build.yml
            parameters:
              keystoreFileName'TodoAppStagingKeystore.jks'
              keystorePassword$(keystore.password)
              keyAlias$(key.alias)
              keyPassword$(key.password)
              artifactName$(stagingArtifact)
              environment"staging"
              gradletasks'assembleStaging'
              buildType'staging'
              googleServiceFilePath'google-services.json'

      - jobStagingBuild_iOS
        displayNameStaging iOS Build
        pool:
          vmImage$(vmImageName)
        steps:
          - taskreplacetokens@3
            inputs:
              rootDirectory'./src/environments/'
              targetFiles'**/*.staging.ts'
              encoding'auto'
              writeBOMtrue
              actionOnMissing'warn'
              keepTokenfalse
              tokenPrefix'#{'
              tokenSuffix'}#'
            displayName"Replace environment variables"

          - templateios-build.yml
            parameters:
              provisioningProfileName$(Staging_ProvProfileName)
              provisioningProfileFilePath'Staging_ios_Profile.mobileprovision'
              certificateSecureFileName'Staging_iOS_certificates.p12'
              certificatePassword$(staging_ios_certificate_password)
              appDisplayName'$(Staging_iOS_DisplayName)'
              appBundleId$(Staging_iOS_AppBundleID)
              developmentTeamId$(Staging_iOS_DevelopmentTeamId)
              artifactName$(stagingiOSArtifact)
              environment"staging"
              firebasePlistFilePath"Staging_GoogleService-Info.plist"

  - stageDeploy_Staging
    displayNameStaging Deploy
    dependsOnBuild
    conditionand(succeeded(), eq(variables.isMain, true))
    jobs:
      - deploymentDeploy_Android_Staging
        displayNameDeploy to Android Staging
        pool:
          vmImage$(vmImageName)
        environmentStaging
        strategy:
          runOnce:
            deploy:
              steps:
                - templateandroid-deploy.yml
                  parameters:
                    appCenterServiceConnection'Todo-Android.Staging.Full'
                    appSlug'OrgName/Todo-Android.Staging'
                    appFile'$(Pipeline.Workspace)/$(stagingArtifact)/TodoApp.release.apk'
                    releaseNotes'Deploy to staging'
                    distributionGroupId$(android_staging_distributionIds)
                    artifactName$(stagingArtifact)

      - deploymentDeploy_iOS_Staging
        displayNameDeploy to iOS Staging
        pool:
          vmImage$(vmImageName)
        environmentStaging
        strategy:
          runOnce:
            deploy:
              steps:
                - templateios-deploy.yml
                  parameters:
                    serverEndpoint"Todo IOS Staging App Store"
                    appIdentifier$(staging_ios_appIdentifier)
                    artifactName$(stagingiOSArtifact)


ionic-build.yml: This is the template that has the steps to build the ionic project. This template accepts the parameter to build the environment. 

parameters:
  - nameenvironment
    displayName"Ionic Build environment"

steps:
  - taskNodeTool@0
    inputs:
      versionSpec'12.x'
    displayName'Install Node.js'

  - scriptnpm install -g @ionic/cli
    displayName'install ionic cli'

  - taskNpm@1
    inputs:
      workingDir'$(Build.SourcesDirectory)'
      commandinstall
    displayName'npm install'

  - scriptionic build -c=${{ parameters.environment }}
    displayName'build ionic'


Build and Deploy Android

ionic-build.yml

# Starter pipeline

parameters:
  - namekeystoreFileName
    displayName"The keystore file name for signing the apk"
    typestring
  - namekeystorePassword
    displayName"Password for the keystore"
    typestring
  - namekeyAlias
    displayName"key alias"
    typestring
  - namekeyPassword
    displayName"Key password"
    typestring
  - nameartifactName
    displayName"Name of the artifact"
    typestring
  - nameenvironment
    displayName"Ionic Build environment"
    typestring
  - namegradleTasks
    displayName"Gradle tasks for buildpossible valuesassembleRelease(for prod), assembleStaging"
    typestring
  - namebuildType
    displayName"Custom Build type, possible values: staging, release(for prod)"
    typestring
  - namegoogleServiceFilePath
    displayName"Google services file"
    typestring

steps:
  - templateionic-build.yml
    parameters:
      environment"${{ parameters.environment }}"

  - scriptnpx cap sync android
    displayName'Sync android'

  - taskDownloadSecureFile@1
    namegoogleServicesFile
    displayName'google services File'
    inputs:
      secureFile'${{ parameters.googleServiceFilePath }}'
  - script: |
      echo googleServicesFile downloaded to $(googleServicesFile.secureFilePath)
      mv $(googleServicesFile.secureFilePath) android/app/google-services.json
    displayName'Copy google service file'

  - taskUpdateAndroidVersionGradle@1
    inputs:
      buildGradlePath'android/app/build.gradle'
      versionCode'$(Build.BuildID)'
      versionName'$(VersionName)'

  - taskGradle@2
    inputs:
      workingDirectory$(system.defaultWorkingDirectory)/android
      gradleWrapperFile'$(system.defaultWorkingDirectory)/android/gradlew'
      gradleOptions'-Xmx3072m'
      publishJUnitResultsfalse
      tasks${{ parameters.gradletasks }}
    displayName'build android project'

  - taskAndroidSigning@3
    displayName'Sign the apk'
    inputs:
      apkFiles'**/app*.apk'
      apksigntrue
      apksignerKeystoreFile'${{ parameters.keystoreFileName }}'
      apksignerKeystorePassword'${{ parameters.keystorePassword }}'
      apksignerKeystoreAlias'${{ parameters.keyAlias }}'
      apksignerKeyPassword'${{ parameters.keyPassword }}'
      apksignerArguments--out $(Build.SourcesDirectory)/android/app/build/outputs/apk/${{ parameters.buildType }}/TodoApp.release.apk --verbose
      zipaligntrue

  - publish$(Build.SourcesDirectory)/android/app/build/outputs/apk/${{ parameters.buildType }}
    artifact'${{ parameters.artifactName }}'


All the steps in the stage define themselves very well. I will explain a few of them

- The most important part is the build type for the android build step.

tasks${{ parameters.gradletasks }}
The ionic capacitor is code once and configures everywhere, for the Android build we would need to configure a build variant for
the staging environment. This will help you change the AppIcon or AppId of the staging to make sure everyone can install the
different flavors of the app on the phone. You need to add the variant in the build.gradle file

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}

staging {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
applicationIdSuffix ".staging"
matchingFallbacks = ['release']
}

}

sourceSets { staging { res.srcDirs = ['src/staging/res'] } }

you can add the new sourceset for the new environment and add the strings.xml to override parameters like
changing the appname or appicon.


- The ionic capacitor sync command will build the Ionic project and copy the assets to the android project and any
another native environment you have in the project.

- Android signing task will read the keystore file from the Secure files and other paramters defined in the pipeline variables.

- publish is the new yaml pipeline syntax to publish the build artifacts.

That completes the Build my app part of the pipeline for the android Now, the next part is to distribute the app to AppCenter.

Deployment to AppCenter

Pre-requisites: You should configure the app center service connection with Azure Devops.

The ionic deploy template contains the steps to deploy the android build to AppCenter.

parameters:
  - nameappCenterServiceConnection
    displayName'Name of the service connection with App Center'
  - nameappSlug
    displayName'Path to application in AppCenter'
  - nameappFile
    displayName'Path of the apk to deploy'
  - namereleaseNotes
    displayName'Release notes'
  - namedistributionGroupId
    displayName'AppCenter distributionId'
  - nameartifactName
    displayName"Name of the artifact"

steps:
  - downloadcurrent
    artifact'${{ parameters.artifactName }}'
  - taskAppCenterDistribute@3
    inputs:
      serverEndpoint'${{parameters.appCenterServiceConnection}}'
      appSlug'${{ parameters.appSlug }}'
      appFile'${{parameters.appFile}}'
      symbolsOption'Android'
      destinationType'groups'
      releaseNotesOption'input'
      releaseNotesInput'${{parameters.releaseNotes}}'
      distributionGroupId'${{parameters.distributionGroupId}}'

For AppCenter distribution, task follow this document Deploy Azure DevOps Builds with App Center


Build and Deploy iOS

Build

parameters:
  - nameprovisioningProfileFilePath
    displayName"The file path to the .mobileprovision file"
    typestring
  - nameprovisioningProfileName
    displayName"The name of the .mobileprovision file"
    typestring
  - namecertificateSecureFileName
    displayName"Name of the .p12 file"
    typestring
  - namecertificatePassword
    displayName"Password to the .p12 file"
    typestring
  - nameappDisplayName
    typestring
  - nameappBundleId
    typestring
  - namedevelopmentTeamId
    typestring
  - nameartifactName
    typestring
  - nameenvironment
    displayName"Ionic build environment"
    typestring
  - namefirebasePlistFilePath
    displayName"Firebase google services file"
    typestring

steps:
  - templateionic-build.yml
    parameters:
      environment"${{ parameters.environment }}"

  - scriptnpx cap sync ios
    displayName'Sync ios'

  - taskInstallAppleCertificate@2
    inputs:
      certSecureFile"${{ parameters.certificateSecureFileName }}"
      certPwd"${{ parameters.certificatePassword }}"
    displayName"Install AdHoc Certificate"

  - taskInstallAppleProvisioningProfile@1
    inputs:
      provProfileSecureFile"${{ parameters.provisioningProfileFilePath }}"
    displayName"Install AdHoc prov profile"

  - taskUpdateiOSVersionInfoPlist@1
    inputs:
      infoPlistPath"ios/App/App/Info.plist"
      bundleShortVersionString"$(Build.BuildNumber)"
      bundleVersion"$(Build.BuildID)"

  - taskDownloadSecureFile@1
    nameprovProfileAppStore
    displayName"Download AdHoc prov profile"
    inputs:
      secureFile"${{ parameters.provisioningProfileFilePath }}"
  - taskDownloadSecureFile@1
    namefirebasePlist
    displayName"Download Firebase plist"
    inputs:
      secureFile"${{ parameters.firebasePlistFilePath }}"

  - script: |
      echo AdHoc prov profile downloaded to $(provProfileAppStore.secureFilePath)
      mv $(provProfileAppStore.secureFilePath) ios/app
      echo Firebase plist downloaded to $(firebasePlist.secureFilePath)
      mv $(firebasePlist.secureFilePath) ios/app/app/GoogleService-Info.plist
      ls -al ios/app
      ls -al ios/app/app
      ls -al ios/app/app.xcodeproj
      plutil -replace "CFBundleDisplayName" -string "${{ parameters.appDisplayName }}" 'ios/app/app/Info.plist'
      sed -i -e '/PRODUCT_BUNDLE_IDENTIFIER =/ s/= .*/= ${{ parameters.appBundleId }};/' 'ios/app/app.xcodeproj/project.pbxproj'
      sed -i -e '/DEVELOPMENT_TEAM =/ s/= .*/= ${{ parameters.developmentTeamId }};/' 'ios/app/app.xcodeproj/project.pbxproj'
      mkdir output
      more ios/app/app/Info.plist
      more ios/app/app.xcodeproj/project.pbxproj
    displayName"Copy Prov profile & substitute values"

  - taskXcode@5
    inputs:
      actions"build"
      configuration"Release"
      sdk"iphoneos"
      xcWorkspacePath"ios/app/app.xcworkspace"
      scheme"App"
      packageApptrue
      exportPath"output"
      signingOption"manual"
      signingIdentity"iPhone Distribution"
      provisioningProfileName"${{ parameters.provisioningProfileName }}"
      teamId'${{ parameters.developmentTeamId }}'
      publishJUnitResultstrue
      xcodeVersion"default"
  - script: |
      ls output
    displayName'print the path to the downloaded folder'

  - publishoutput
    artifact${{ parameters.artifactName }}

There are lot of things going on in the script.

- Moving the provisioning profile to the project folder
- Move the google services info plist file
- change the display name for the app
- change the bundle identifier for the app
- Set the Development team

Deploy

Pre-requisite: You would need an app-specific password for the tool to work.

I am using an XCode deploy to deploy the app to TestFlight

parameters:
  - nameappIdentifier
    typestring
  - nameartifactName
    typestring
  - nameserverEndpoint
    typestring

steps:
  - downloadcurrent
    artifact${{parameters.artifactName}}
  - script: |
      ls '$(Pipeline.Workspace)/${{parameters.artifactName}}'
    displayName'print the path to downloaded folder'

  - script: |
      sudo xcode-select -r
      xcrun altool --upload-app -f '$(Pipeline.Workspace)/${{parameters.artifactName}}/App.ipa' -t ios -u $(ios_appstore_useremail) -p $(staging_ios_app_specific_password)
    displayName'upload the ipa to AppStore'

That is how you can get the Ionic Capacitor app deployed to AppCenter and eventually to the devices and to the TestFlight for iOS

Please do let me know if you have any questions.

No comments:

Post a Comment