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.
Once you select your repo you will get some recommendations in the Configure
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:
- script: echo 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 lets to create 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.
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
- task: replacetokens@3
inputs:
rootDirectory: './src/environments/'
targetFiles: '**/*.staging.ts'
encoding: 'auto'
writeBOM: true
actionOnMissing: 'warn'
keepToken: false
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:
- stage: Build
displayName: Build Staging
jobs:
- job: StagingBuild_Android
displayName: Build Staging app
pool:
vmImage: $(vmImageName)
steps:
- task: replacetokens@3
inputs:
rootDirectory: './src/environments/'
targetFiles: '**/*.staging.ts'
encoding: 'auto'
writeBOM: true
actionOnMissing: 'warn'
keepToken: false
tokenPrefix: '#{'
tokenSuffix: '}#'
displayName: "Replace environment variables"
- template: android-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'
- job: StagingBuild_iOS
displayName: Staging iOS Build
pool:
vmImage: $(vmImageName)
steps:
- task: replacetokens@3
inputs:
rootDirectory: './src/environments/'
targetFiles: '**/*.staging.ts'
encoding: 'auto'
writeBOM: true
actionOnMissing: 'warn'
keepToken: false
tokenPrefix: '#{'
tokenSuffix: '}#'
displayName: "Replace environment variables"
- template: ios-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"
- stage: Deploy_Staging
displayName: Staging Deploy
dependsOn: Build
condition: and(succeeded(), eq(variables.isMain, true))
jobs:
- deployment: Deploy_Android_Staging
displayName: Deploy to Android Staging
pool:
vmImage: $(vmImageName)
environment: Staging
strategy:
runOnce:
deploy:
steps:
- template: android-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)
- deployment: Deploy_iOS_Staging
displayName: Deploy to iOS Staging
pool:
vmImage: $(vmImageName)
environment: Staging
strategy:
runOnce:
deploy:
steps:
- template: ios-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:
- name: environment
displayName: "Ionic Build environment"
steps:
- task: NodeTool@0
inputs:
versionSpec: '12.x'
displayName: 'Install Node.js'
- script: npm install -g @ionic/cli
displayName: 'install ionic cli'
- task: Npm@1
inputs:
workingDir: '$(Build.SourcesDirectory)'
command: install
displayName: 'npm install'
- script: ionic build -c=${{ parameters.environment }}
displayName: 'build ionic'
Build and Deploy Android
ionic-build.yml
# Starter pipeline
parameters:
- name: keystoreFileName
displayName: "The keystore file name for signing the apk"
type: string
- name: keystorePassword
displayName: "Password for the keystore"
type: string
- name: keyAlias
displayName: "key alias"
type: string
- name: keyPassword
displayName: "Key password"
type: string
- name: artifactName
displayName: "Name of the artifact"
type: string
- name: environment
displayName: "Ionic Build environment"
type: string
- name: gradleTasks
displayName: "Gradle tasks for build: possible values: assembleRelease(for prod), assembleStaging"
type: string
- name: buildType
displayName: "Custom Build type, possible values: staging, release(for prod)"
type: string
- name: googleServiceFilePath
displayName: "Google services file"
type: string
steps:
- template: ionic-build.yml
parameters:
environment: "${{ parameters.environment }}"
- script: npx cap sync android
displayName: 'Sync android'
- task: DownloadSecureFile@1
name: googleServicesFile
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'
- task: UpdateAndroidVersionGradle@1
inputs:
buildGradlePath: 'android/app/build.gradle'
versionCode: '$(Build.BuildID)'
versionName: '$(VersionName)'
- task: Gradle@2
inputs:
workingDirectory: $(system.defaultWorkingDirectory)/android
gradleWrapperFile: '$(system.defaultWorkingDirectory)/android/gradlew'
gradleOptions: '-Xmx3072m'
publishJUnitResults: false
tasks: ${{ parameters.gradletasks }}
displayName: 'build android project'
- task: AndroidSigning@3
displayName: 'Sign the apk'
inputs:
apkFiles: '**/app*.apk'
apksign: true
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
zipalign: true
- 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:
- name: appCenterServiceConnection
displayName: 'Name of the service connection with App Center'
- name: appSlug
displayName: 'Path to application in AppCenter'
- name: appFile
displayName: 'Path of the apk to deploy'
- name: releaseNotes
displayName: 'Release notes'
- name: distributionGroupId
displayName: 'AppCenter distributionId'
- name: artifactName
displayName: "Name of the artifact"
steps:
- download: current
artifact: '${{ parameters.artifactName }}'
- task: AppCenterDistribute@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:
- name: provisioningProfileFilePath
displayName: "The file path to the .mobileprovision file"
type: string
- name: provisioningProfileName
displayName: "The name of the .mobileprovision file"
type: string
- name: certificateSecureFileName
displayName: "Name of the .p12 file"
type: string
- name: certificatePassword
displayName: "Password to the .p12 file"
type: string
- name: appDisplayName
type: string
- name: appBundleId
type: string
- name: developmentTeamId
type: string
- name: artifactName
type: string
- name: environment
displayName: "Ionic build environment"
type: string
- name: firebasePlistFilePath
displayName: "Firebase google services file"
type: string
steps:
- template: ionic-build.yml
parameters:
environment: "${{ parameters.environment }}"
- script: npx cap sync ios
displayName: 'Sync ios'
- task: InstallAppleCertificate@2
inputs:
certSecureFile: "${{ parameters.certificateSecureFileName }}"
certPwd: "${{ parameters.certificatePassword }}"
displayName: "Install AdHoc Certificate"
- task: InstallAppleProvisioningProfile@1
inputs:
provProfileSecureFile: "${{ parameters.provisioningProfileFilePath }}"
displayName: "Install AdHoc prov profile"
- task: UpdateiOSVersionInfoPlist@1
inputs:
infoPlistPath: "ios/App/App/Info.plist"
bundleShortVersionString: "$(Build.BuildNumber)"
bundleVersion: "$(Build.BuildID)"
- task: DownloadSecureFile@1
name: provProfileAppStore
displayName: "Download AdHoc prov profile"
inputs:
secureFile: "${{ parameters.provisioningProfileFilePath }}"
- task: DownloadSecureFile@1
name: firebasePlist
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"
- task: Xcode@5
inputs:
actions: "build"
configuration: "Release"
sdk: "iphoneos"
xcWorkspacePath: "ios/app/app.xcworkspace"
scheme: "App"
packageApp: true
exportPath: "output"
signingOption: "manual"
signingIdentity: "iPhone Distribution"
provisioningProfileName: "${{ parameters.provisioningProfileName }}"
teamId: '${{ parameters.developmentTeamId }}'
publishJUnitResults: true
xcodeVersion: "default"
- script: |
ls output
displayName: 'print the path to the downloaded folder'
- publish: output
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:
- name: appIdentifier
type: string
- name: artifactName
type: string
- name: serverEndpoint
type: string
steps:
- download: current
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