Artifact versioning in Azure DevOps

Last updated: 12 August 2021

Azure DevOps is a great platform for all things continuous - integration, deployment and annoyance.

The last one has been high on my list for the last while, trying to get Plumber builds to plop out of the pipeline in the shape I wanted.

I’m building a multi-target solution, targeting .Net Framework 4.7.2 and .Net 5.0, which took a bit of fiddling to get right, but that’s not the source of my most recent pains.

Versioning. Versioning is the devil and she’s had me grasped in her hairy paws.

What I want is simple - semantic (ish) versioning, but with enough flexibility to easily publish pre-release versions, nightly versions, RTM versions and any other versions I might so desire at some point in the future.

As part of the Great Net Core migration of early-mid 2021, I’ve shifted Plumber away from package.config to use the much more modern package references syntax in the csproj file. Since csproj is now my source of truth for references, it made sense that it became the same for packaging info.

That meant goodbye, farewell, to nuspec in favor of properties in my csproj file(s). All good, nice and clean.

Enter Directory.Build.props. This magical file lets me list all the global/reused properties, and have them imported per project at build time.

For example, I can have a csproj like so (edited for brevity):

<Project>
  <PropertyGroup>
    <Title>Plumber.Core</Title>
    <Summary>The Plumber.Core library - do not install this package directly, install Plumber.Workflow instead</Summary>
  </PropertyGroup>
</Project>  

For more example, I can have a Directory.Build.Props file like so (also edited for brevity):

<Project>
  <PropertyGroup>
    <VersionPrefix>2.0.0</VersionPrefix>
    <VersionSuffix>rc-001</VersionSuffix>
  </PropertyGroup>
</Project>

When it comes to build time, these get combined into:

<Project>
  <PropertyGroup>
    <Title>Plumber.Core</Title>
    <Summary>The Plumber.Core library - do not install this package directly, install Plumber.Workflow instead</Summary>
    <VersionPrefix>2.0.0</VersionPrefix>
    <VersionSuffix>rc-001</VersionSuffix>
  </PropertyGroup>
</Project>

This means I can set the VersionPrefix and VersionSuffix once, at the solution root, and have these values applied to all projects when I build (or more accurately, when DevOps builds them).

With the entree out of the way, we get to the meat.

It’s great to be building and packing packages with a sensible versioning strategy - per the examples above, Plumber is currently 2.0.0-rc-001. There may be more Release Candidate releases in future, in which case I update the props file, and the subsequent build will update.

The issue I struck, however, was in managing nightly releases. I don’t want a daily bump to the RC value, I need instead to include the build number as an additional identifier. A suffixed suffix, if you will.

There’s more than likely other ways to get this to work, but those are hidden somewhere I couldn’t find, so I took what could be seen as a sledgehammer approach.

Power. Shell.

For all the slander cast YAML’s way, I’m a fan of having my pipeline declared in code with as little as possible hidden away in SAAS menus and settings. Hence, Powershell for this job.

My ideal package versioning format looks like so:

Major.Minor.Patch-Suffix.Build

or

2.0.0-rc-001.323

With the major, minor, patch and suffix values available in the props file, and DevOps kindly providing the build number as an environment variable, all that remains is some Powershell gymnastics.

$xml = [Xml] (Get-Content .\Directory.Build.props)
$prefix = $xml.Project.PropertyGroup.VersionPrefix
$suffix = $xml.Project.PropertyGroup.VersionSuffix   
echo "##vso[task.setvariable variable=VERSION]$prefix-$suffix.$Env:BUILD_BUILDID"

I’m geting the Directory.Build.props file as an XML object, traversing the nodes to get the VersionPrefix and VersionSuffix values, then banging them into a variable along with $Env:BUILD_BUILDID, which spits out the current build ID (as one might fairly expect).

Now that I have my VERSION variable, I can use it in a subsequent task, when I’m packing the NuGet packages that comprise Plumber. This could be a script/CLI task, but using the DevOps DotNetCoreCLI@2 task, I can manage versioning with task inputs:

- task: DotNetCoreCLI@2
  inputs:
    // other stuff
    versioningScheme: byEnvVar
    versioningEnvVar: VERSION

Easy as that. Easy in the sense it took me best part of a day and dozens (probably) of failed pipeline runs to get the result I was chasing.

Next job is to update the pipeline to run release and pre-release builds based on the trigger, where pushing a release tag sets the release configuration.

Then it’s on to deployment, and pushing that RTM build to NuGet, creating a GitHub release, automatically tweeting my successes and ordering a delicious, celebratory Indian dinner.

Small steps.