This week we set up a CI/CD pipeline for one of our UWP apps that needs to be sideloaded because we don't publish it through the Microsoft Store. This article was super useful in getting me started. Microsoft has made things quite easy with Azure DevOps, but there were a few things that took us a little bit of time to figure out.
A little bit of context
We have an enterprise UWP application that we publish via an http server. We also have a custom auto-update mechanism that updates the apps whenever an update is available. You can learn more about the auto-updater here.
So what we needed, is a build pipeline that builds the app for both x64
and x86
and generate an index.html page, appinstaller
file and the msixbundle
files for both the app and its dependencies.
The plan
- Setup a build pipeline to generate update package and increase version
- Set up a release pipeline to upload the update package via FTP
- Celebrate
1. Setting up a build pipeline
This article provides most of the information needed. But I have a few notes:
1.1 Use self hosted agents
Unfortunately, it seems like Microsoft hosted agents are not powerful enough to build release modes of moderate to big UWP applications. Because in release mode, it has to compile the app natively and so uses the native compilation toolchain. In our experience, the native compilation toolchain requires at least 8 GB of RAM to work properly. For our app, the Microsft hosted agents had a build success rate of less than 50%. They fail because of memory usage issues.
Besides, hosted agents are much faster. Microsoft hosted agents would take about 45 minutes to build our app, our own hosted agents usually take about 10 minutes. This is partly because they use incremental build, instead of a clean build every time they need to build the app.
So you have to use your own hosted agents. You use your own machines or virtual machines to build the application. Adding an agent to a pool is very easy:
- Go to project settings > Agent Pools
- Click on "Default" or create another pool
- Click on "New Agent" and follow the instructions
Now you will need to tell the build pipeline to use your self-hosted agents.
You can specify the pool name very easily in yaml:
pool:
name: "Default"
You might also want to use the 64 bit compiler, for that add this xml snippet a PropertyGroup
in your project's csproj
file:
<Use64Bitcompiler>true</Use64Bitcompiler>
1.2 Use Nuget version 4.x
For some reason, not specifying Nuget version leads to the failure of the build.
1.3 Using Extension SDKs with a build pipeline
An Extension SDK is similar in concept to a regular assembly reference, but is instead a rich collection of files to cover various configurations and design time scenarios.
If you're using an SDK reference, for example if you use SQLite for UWP, you must include the SDK with the source code and tell MSBuild to use the local version before searching for the SDK in the global folder. This article explains the steps, but here is a summary:
- Create a folder beside your solution file and call it
SDKs
. - Copy UAP folder from
C:\Program Files (x86)\Microsoft SDKs
into theSDKs
folder beside your solution file. - Add this snippet to the
.csproj
file:
<PropertyGroup>
<SDKReferenceDirectoryRoot>$(SolutionDir)\SDKs;$(SDKReferenceDirectoryRoot)</SDKReferenceDirectoryRoot>
</PropertyGroup>
1.4 Versioning the packages
Usually the version of the package is stored inside Package.appxmanifest
and auto-incremented by Visual Studio. But this requires you to commit the changes to make sure you don't reuse versions.
I am not comfortable with build pipelines committing changes to source code. Fortunately there are a few extensions for Azure DevOps that can help with this. The one I decided to use is Version Number counter. This article does a great job of explaining how you can use it.
What we can do is have a PowerShell script update the app manifest file:
# https://stackoverflow.com/a/42699995
$xmlFileName = "$(Build.SourcesDirectory)\{ProjectName}\Package.appxmanifest"
[xml]$xmlDoc = Get-Content $xmlFileName
$xmlDoc.Package.Identity.Version = "$env:appVersion.0"
echo 'New version:' $xmlDoc.Package.Identity.Version
$xmlDoc.Save($xmlFileName)
For that you need to define a variable called appVersion
with the initial version in this format: 1.0.0
. Because UWP package version format is like 1.0.0.0
, you have to concatenate another "0" at the end of appVersion
.
If you're a desktop developer, you might not like PowerShell or bash scripts. But they are very powerful and flexible for automation scenarios.
1.5 Use templates
Sometimes you have multiple configurations and environments you want to build for. We wanted to be able to build for both Production and Staging environments. Each of which have different build configuration (Release, Debug, Staging...), supported different build platforms (x86, x64, ARM), had different auto-update URLs, etc..
Azure DevOps yaml files support templates. You define a base template and put all of the common steps and jobs there, then you define a bunch of parameters for the template so that the pipelines that inherit from the base template can configure these parameters.
The code
Template.yaml
parameters:
# Platforms to generate bundles for: x86, x64, x86|x64
buildPlatform: 'x86|x64'
# Platforms to generate bundles for (Debug, Release...)
buildConfiguration: 'Release'
# Where will the installer be uploaded to?
installerUrl: 'Replace this by the actual URL'
# The version of the app
appVersion: '1.1.1'
solution: '{ProjectName}.sln'
appxPackageDir: '$(build.artifactStagingDirectory)\AppxPackages\\'
steps:
- task: PowerShell@2
env:
appVersion: ${{ parameters.appVersion }}
buildPlatform: ${{ parameters.buildPlatform }}
buildConfiguration: ${{ parameters.buildConfiguration }}
installerUrl: ${{ parameters.installerUrl }}
displayName: 'Print variables'
inputs:
targetType: 'inline'
script: |
echo "appVersion: $env:appVersion"
echo "buildPlatform: $env:buildPlatform"
echo "buildConfiguration: $env:buildConfiguration"
echo "installerUrl: $env:installerUrl"
- task: versioncounter@1
inputs:
VersionVariable: 'appVersion'
UpdateMinorVersion: true
DevOpsPat: '{Your_PAT}'
- task: PowerShell@2
env:
appVersion: ${{ parameters.appVersion }}
displayName: 'Replace Version Number in the manifest file'
inputs:
targetType: 'inline'
script: |
# https://stackoverflow.com/a/42699995
$xmlFileName = "$(Build.SourcesDirectory)\{ProjectName}\Package.appxmanifest"
[xml]$xmlDoc = Get-Content $xmlFileName
$xmlDoc.Package.Identity.Version = "$env:appVersion.0"
echo 'New version:' $xmlDoc.Package.Identity.Version
$xmlDoc.Save($xmlFileName)
- task: NuGetToolInstaller@1
inputs:
versionSpec: '4.9.2'
- task: NuGetCommand@2
inputs:
command: 'restore'
restoreSolution: '${{ parameters.solution }}'
- task: VSBuild@1
inputs:
solution: '${{ parameters.solution }}'
msbuildArgs: '/p:AppxBundlePlatforms="${{ parameters.buildPlatform}}" /p:AppxPackageDir="${{ parameters.appxPackageDir}}" /p:AppxBundle=Always /p:UapAppxPackageBuildMode=SideloadOnly'
platform: 'x64'
configuration: '${{ parameters.buildConfiguration }}'
msbuildArchitecture: 'x64'
- task: PowerShell@2
env:
installerUrl: ${{ parameters.installerUrl }}
displayName: 'Replace installer url'
inputs:
targetType: 'inline'
script: |
# https://stackoverflow.com/a/17144445
$fileName = "$(build.artifactStagingDirectory)\AppxPackages\{ProjectName}.appinstaller"
$original = "{Original Url in the .appinstaller file}"
$content = Get-Content $fileName
$content = $content.replace($original, $env:installerUrl)
Set-Content -Path $fileName -Value $content
echo 'Install Url:' $env:installerUrl
- task: CopyFiles@2
displayName: 'Copy Files to: $(build.artifactstagingdirectory)'
inputs:
SourceFolder: '$(system.defaultworkingdirectory)'
Contents: '**\bin\${{ parameters.buildConfiguration }}\**'
TargetFolder: '$(build.artifactstagingdirectory)'
- task: PublishBuildArtifacts@1
displayName: 'Publish Artifact: drop'
inputs:
PathtoPublish: '$(build.artifactstagingdirectory)'
Staging.yml
# Staging
trigger:
- master
pool:
name: "Default"
steps:
- template: Template.yml
parameters:
buildPlatform: 'x64'
buildConfiguration: 'Release'
installerUrl: '{Staging_URL}'
appVersion: $(appVersion)
Production.yml
# Production
trigger: none
pool:
name: "Default"
steps:
- template: Template.yml
parameters:
buildPlatform: 'x86|x64'
buildConfiguration: 'Release'
installerUrl: '{Production_URL}'
appVersion: $(appVersion)
2. Setup a release pipeline
The release pipeline is very easy, since all of the hard work is done in the build pipeline. The only thing you have to do is:
Get the build artifacts from the build pipeline. You can do that by clicking "Artifacts" part. Select "Build" from "Source Type" and select the appropriate build pipeline.
Upload the
index.html
,{ProjectName}.appinstaller
and the package folder to the update website via FTP. Azure DevOps has a built-in task for FTP upload.
3. Celebrate 🎉
The title pretty much says it all.
Top comments (3)
Super useful article! Awesome job.
Did you have to do anything special for the certificate in Azure Pipelines so the app can be sideloaded by end users? I'm working on getting a side project set up but there seems to be a discrepancy between VS2017 and VS2019 where the latter doesn't create a temporary cert file.
Thanks! Since our app already had a certificate, I didn't face such an issue. But this seems to have already been solved.
I hadn't seen that one in my searches and it seems to be what I need. Thank you!