How to continuously deploy NuGet packages with Azure DevOps and GitVersion

Code

After getting bored with manually bumping version numbers and running PowerShell scripts to deploy NuGet packages, I started to look for resources on how to automate this process with Azure DevOps pipelines. I came across a good article on continuous delivery of NuGet packages for Azure artifacts by Utkarsh Shigihalli, a Microsoft MVP. This covers a lot of the ground required, including the branching strategy, developer workflow and pipeline approach. However, once I had read it there were still a few issues that it didn't quite answer for me, and some out of date information, so I thought I'd write this post to set things out simply with a working example on GitHub.

The pipeline tasks to build, pack and push a NuGet package are fairly straightforward and well documented, but the final piece of the puzzle is a process to automatically increment the package semantic version. The standard approach for .NET Core is via a line in the csproj file or via variables in the deployment pipeline, but this still requires manual intervention which interrupts continuous deployment.

GitVersion to the Rescue

Utkarsh's article is well worth a read, but in summary it suggests using the GitVersion tool to handle the automation of your team's chosen versioning strategy. His team use the GitHub flow approach, but the tool is also optimised for GitFlow and can be customised to whatever strategy you like. The tool relies on a simple configuration file (GitVersion.yml) in your repository to automatically determine the semantic version based on the commit history on the repository.

This is one of the bits where Utkarsh's article didn't quite make things clear and I had to dig into the GitVersion docs a bit to understand how the version number is calculated. His GitVersion.yml file looks like this:

mode: Mainline
next-version: 1.0.0
branches:
  master:
    tag: ''
  feature:
    tag: alpha
ignore:
  sha: []
merge-message-formats: {}

I'll run through this and explain how it works. The mainline mode is used for GitHub flow because it tells GitVersion to infer releases from merges and commits to master. The next-version value basically tells GitVersion where to start. So if we initialise GitVersion with this setting and then make a direct commit to master this will return the version as 1.0.1. Merge another branch into master and that will increment the version to 1.0.2 and so on. So each time GitVersion is run it works back through commits to calculate the version number.

This gives us a way to automatically handle version numbers sequentially for each build as required by immutable NuGet package versions.

The next part of the config file specifies a suffix to apply to the version based on the branch. As master is considered the release version this has an empty value. In the above configuration, and commit to a feature branch will add an alpha tag, which is supported by NuGet as a means to indicate a prerelease version. As an example, the above configuration would give the following:

There are then various ways in which you can increment the minor and major versions, which Utkarsh covered off in more detail. Basically you can use the branch name (feature/my-feature-1.2.0) or a tag on the commit or +semver: major/minor in the commit message. It was the automated calculation based on the commits that he didn't make crystal clear, these parts are simple enough.

Putting it all together

So with the background to GitVersion in place how does this work in Azure DevOps?

Step 1: Command Line Tool

Install the command line tool on your local machine. I'm on Mac OS so I used HomeBrew:

brew install gitversion

Then open a terminal in the root of your repo and run:

gitversion init

This will provide options to create your GitVersion.yml file.

Step 2: Azure DevOps Extension

Now you need to add GitVersion to your organization via the marketplace. Here is an important point where Utkarsh's post is now out of date as the version he links to is now deprecated. The latest version is UseGitVersion.

Step 3: Update the Pipeline yaml

So with the newer Azure task, I was not able to follow Utkarsh's example yaml, and the task settings suggested in the GitVersion documentation caused me a few headaches. They give the following:

variables:
  GitVersion.SemVer: ''

steps:
- task: UseGitVersion@5
  displayName: GitVersion
  inputs:
    versionSpec: '5.x'
    updateAssemblyInfo: true

The bit that causes the problems is  updateAssemblyInfo: true. This will attempt to update version numbers in AssemblyInfo.cs, but obviously in the .NET Core world we've said goodbye to this file and it is automatically generated from the information in the csproj file on build. From the documentation it looks like the command line tool can be provided with a flag to tell it to create the file if it does not exist, but it looks like no such option exists for the DevOps task. This means that it throws an error when it cannot find the file.

My first approach to resolve this was to create the file and add only the relevant attributes:

[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0")]
[assembly: AssemblyVersion("1.0.0.0")]

This gave me a new error, which looks like a known issue in that you actually need to specify the path to the file. With the file specified it was happy days, the GitVersion task would update the values and these would get used in the build, or at least that's more or less true.

However, this isn't quite right. Looking at Utkarsh's build step I noticed that he passes a version parameter as an argument on the dotnet build command:

arguments: '--configuration $(BuildConfiguration) /p:Version=$(GitVersion.NuGetVersion)' 

This lead to a bit of research on the plethora of version numbers that Microsoft use and lead to a nice article by Andrew Lock. Basically this boils down to the version property being the only one you really need to worry about as it replaces any values set for suffix or prefix. So in effect what supplying the version parameter does is to write a new version number into the build output and we can remove the <version>x.x.x</version> tag from the csproj file and there is no need to have an old fashioned AssemblyInfo.cs in your solution. So my GitVersion task looks like this:

- task: UseGitVersion@5
  displayName: GitVersion
  inputs:
    versionSpec: '5.x'

If you are not in .NET Core land and you want to be able to tweak the assembly information more than GitVersion does then I found an article explaining how to update the Assembly settings using the Assembly Info task. This task has flavours for both .NET framework and .NET Core so you have options if you need more control.

The final bit of versioning that needs to happen is the PackageVersion, which is used to generate the NuGet package version when building a package using dotnet pack. This is handled by the parameters available on the DotNetCoreCLI@2 pack:

- task: DotNetCoreCLI@2
  displayName: "dotnet pack"
  inputs:
    command: 'pack'
    packagesToPack: '**/DemoPackage.csproj'
    nobuild: true
    packDirectory: '$(Build.ArtifactStagingDirectory)'
    versioningScheme: byEnvVar
    versionEnvVar: 'GitVersion.NuGetVersion'

Demo Project

I have created a full working example of this on GitHub which uses my Azure DevOps to run the yaml and push to a public artifacts feed. The package provides methods to enable you to display the various version values to see what you get.

Feel free to submit a pull request to see the automated versioning in action. Hopefully that repository gives a clear view of what is required to set up continuous deployment for NuGet packages. If you need to see an example that pushes to nuget.org instead you can view that on my SimpleDatastore project. This uses the NuGetCommand@2 command and a service connection to authenticate.