Been building up and publishing Bad Echo tech code for a little bit now and, at least until recently, spared little thought for the versioning of its comprising libraries.

Things have changed quite a bit in regards to .NET versioning standards and practices since I last took a serious look at it years ago (this was prior to the existence of .NET Core), and my knowledge of the subject needed some refreshing.

And some refreshing, my knowledge has received. This article contains the bits of information I’ve deemed to be most pertinent in regards to sensible .NET versioning, including best practices and my simple system for automating Bad Echo’s own assemblies and packages.

What Kind of Versioning Do We Want?

Unlike the last time I investigated the “proper versioning” of program files, semantic versioning is all the rage these days, and is employed by for lots of software packages.

It’s good to have some sort of standard for how one goes about implementing something as important as versioning, so I felt it was important to be able to support a kind of semantic versioning that best served what I wanted.

This article isn’t meant to go into the specifics of what semantic versioning at all, but what I essentially wanted was for the versions of Bad Echo’s program files and packages to be using the following formats:

Stable Release Version Format

Major.Minor.Patch

Prerelease Version Format

Major.Minor.Patch-PrereleaseId.Build[+Metadata]

Where:

  • the major, minor, and patch version numbers are simple number identifiers,
  • the prerelease identifier is a string denoting the “degree” of stability such as alpha, beta, etc.,
  • the build version number is essentially a counter incremented each time a new build is created using the specified prelease identifier, and
  • the build metadata is the short hash for the Git commit responsible for the build (with the important caveat that this metadata not actually be a part of the package version, as NuGet and GitHub start to get real whiny otherwise).

Okay. Goals defined. Now where do we tweak this versioning stuff?

Version Settings Location

Back in the days of yore, when .NET Framework reigned supreme, all settings relating to assembly versioning most likely resided in the project’s AssemblyInfo.cs file, which is the traditional living space for all assembly-level attributes.

Assembly-Level Version Attributes

[assembly: ComVisible(false)]
[assembly: Guid("19d8e431-bf6c-46bd-ac7b-4195fa909ea4")]
[assembly: AssemblyVersion("1.2.3.4")]
[assembly: AssemblyFileVersion("1.2.3.4")]
// etc.

Jumping forward to modern times, where .NET 6 now rules the land, placing version-related settings in AssemblyInfo.cs has fallen out of fashion. Instead of assembly-level attributes, most of these types of things are now set using MSBuild properties found in the .NET project file.

MSBuild Project File Version Settings

<Project>
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <AssemblyVersion>1.2.3.4</AssemblyVersion>
    <FileVersion>1.2.3.4</FileVersion>
    <!--etc.-->
  </PropertyGroup>
</Project>

This is nice as it allows us to take advantage of several features such as Directory.Build.props files, which we can use to apply version information to entire directory structures (read: one source-of-truth version for multiple projects, if desired).

With our decision to configure our settings using version MSBuild properties made, let’s set up a few fundamental property values first.

Base Version Properties

We defined some basic terms above such as the major, minor, and patch version numbers. They exist in text, so let’s now bring these concepts to life in our code.

As an example, let’s assume we have a software project whose stable package version is currently at 1.2.3. We’d define our base properties like so:

Base Properties in Project File

<PropertyGroup>
  <MajorVersion>1</MajorVersion>
  <MinorVersion>2</MinorVersion>
  <PatchVersion>3</PatchVersion> 
  <VersionPrefix>$(MajorVersion).$(MinorVersion).$(PatchVersion)</VersionPrefix>
</PropertyGroup>

None of the above property values have any effect yet on our compiled product; these are all new properties we’ve just defined that will come into play once we start dealing with all the various built-in .NET version settings.

The first three properties are self-explanatory if you read the section above where we defined our goals.

Following the first three properties, the VersionPrefix property specifies exactly that: the format used for the first part of our version, which may or may not be followed by an additional suffix (stable releases will lack the suffix, whereas prereleases will have the suffix).

With these core properties set, let’s start to look at all the version-related settings we need to be cognizant about in .NET-land. Note that there are more settings pertaining to the version than what’s shown below, but we don’t need to deal with them for our purposes.

Setting #1: The Package Version

The setting we’ll be looking at first is the most public-facing version of a software project: the package version.

It’s arguably the most important of all version numbers, as most people are going to be thinking of the package version when choosing between versions of some desired software. Deliverables are everything, and the package version essentially discriminates between what is delivered.

The package version can be directly changed to be whatever we wish it to be by setting the PackageVersion MSBuild setting in the project file.

Package Version in Project File

<PropertyGroup>
  <PackageVersion>1.2.3-beta.1</PackageVersion>
</PropertyGroup>

The package version setting manifests itself when we generate the NuGet code packages for our software. You’ll see it in the name of the NUPKG file as well as within its metadata.

How To Handle the Package Version

This is where we want our proposed version format to manifest itself, albeit without the build metadata for when we’re dealing with prerelease versions. Clearly, out of all the various version settings, this one is a very important one. So how do we handle it?

Well, we’re going to handle it by not doing anything at all. At least, not with the PackageVersion setting mentioned. This setting inherits from several other settings, such as Version, VersionPrefix, VersionSuffix, etc.

Let’s not mess with that. Instead, we’ll influence its constituent parts using our own properties such as MajorVersion, MinorVersion, et al. mentioned in the previous section.

When all is said and done, the package version will end up being defined by us through the VersionPrefix and, optionally, VersionSuffix properties. How this looks exactly will be demonstrated later when version automation is shown.

Setting #2: The Assembly Version

Let’s now look at the version of the assembly itself.

We can set this from either an assembly-level AssemblyVersionAttribute or via the AssemblyVersion MSBuild setting in the project file.

Assembly Version in Project File

<Project>    
  <PropertyGroup>
    <AssemblyVersion>1.2.3.4</AssemblyVersion>
  </PropertyGroup>
</Project>

What this setting actually affects is not visible on the surface. Given a .NET DLL, we won’t be able to see the assembly version simply from inspecting the file’s properties.

Rather, the assembly version is metadata baked into the assembly used by the .NET runtime itself, and is otherwise invisible to an end user. Calling it the “assembly version” makes the setting sound much more official than it is, as the .NET runtime only ever uses it during the binding of strong-named assemblies.

How To Handle the Assembly Version

Strong name signing, once a commonly recommended practice, is now essentially discouraged by Microsoft ever since the advent of .NET Core.

This greatly lessens the importance of the assembly version. That aside, the assembly version is something that only exists to cause problems: all it serves to do is cause a run-time exception if we attempt to load a strong-named assembly whose version doesn’t match exactly what the runtime was expecting.

So, all of that in mind, it’s best to make the assembly version as ambiguous as possible in order to reduce the possibility of assembly binding issues. The recommended way to do this is to simply have the major version number set, with the rest of the fields left at zero.

Assembly Version in Project File: Handled

<PropertyGroup>
  <!--Leave the assembly version as ambiguous as possible.-->
  <AssemblyVersion>$(MajorVersion).0.0.0</AssemblyVersion>
</PropertyGroup>

Note that while I’m cherry picking this bit of Microsoft-proffered advice from the previous link, we won’t be blindly obeying all the recommendations provided there as they’re using a system of versioning based on Major.Minor.Build.Revision.

And we ain’t having none of that!

Setting #3 The Assembly File Version

The next setting is one that serves as a much more visible indicator of a .NET assembly’s version: the version number for the Win32 file version resource.

That is, the version number of the EXE or DLL file prominently displayed by Explorer (which, for all intents and purposes, is basically the version number).

This is settable either from the assembly-level AssemblyFileVersionAttribute or via the FileVersion MSBuild setting in the project file.

File Version in Project File

<PropertyGroup>
  <FileVersion>1.2.3.4</FileVersion>
</PropertyGroup>

Setting this property only affects the version that gets assigned to the Win32 file version resource, and has no effect on run-time behavior of the program.

How To Handle the File Version

Unlike the assembly version, which we kind of swept under the rug in the previous section, we’ll want to fill in the various version number fields here given the prominence of this particular version label.

File version resources use a four version number field format (1.2.3.4). We can fill up the first three fields using our major, minor, and patch version numbers, but what about that fourth?

As was just indicated, the actual file version we end up using isn’t ever looked at by .NET; it does come into play, however, when we begin speaking of other system services such as the Windows Installer.

What becomes important, in particular when dealing with the aforementioned system, is simply that the file versions of two or more files differ somehow the files themselves…differ!

Differentiating File Version by Builds

Our previously defined prerelease version format includes just a field that will serve our purposes here: the build version number. This is to be our counter of sorts that will, more or less, be incremented every time a new official build of our assembly is created.

This makes the build version number the perfect version number to include at the tail end of our file version.

File Version in Project File: Handled, Somewhat

<PropertyGroup>
  <!--We'll get to how we actually define the build number later..-->
  <FileVersion>$(MajorVersion).$(MinorVersion).$(PatchVersion).$(BuildNumber)</FileVersion>
</PropertyGroup>

What is this BuildNumber property, you ask? It’s another MSBuild property whose definition we’ll actually get to discussing when we’re putting together the versioning automation later on.

Setting #4: The Assembly Informational Version

Onto the final version-related setting we give a damn about!

This time, we’re dealing with a bit of a cluster bomb: the assembly informational version, settable from either the assembly-level AssemblyInformationalVersionAttribute or via the InformationalVersion MSBuild setting in the project file.

Informational Version in Project File

<PropertyGroup>
  <InformationalVersion>1.2.3.4</InformationalVersion>
</PropertyGroup>

“Assembly informational version” is a bit of a mouthful. Microsoft doesn’t make it too obvious in the attribute’s own documentation, but the informational version is really just the file’s product version “in disguise”.

The product version is a version information resource that has existed within Windows for some time. In a way, .NET’s manner of referring to the product version as the informational version falls in line with how its use has changed throughout the years.

Previously used to hold only a standard set of version numbers specific to the product, these days we see the product version being used more and more as a way to provide additional version-related information.

And this is exactly how we’ll be using it for our purposes.

How To Handle the Informational Version

The product version is the place to provide the biggest snapshot of version-related information. Its where our desired release and prerelease version formats will finally appear in their most complete forms.

While our package version will contain a large percentage of our versioning information, it will never contain the build metadata, as NuGet doesn’t like it when we try to engage in any sort of semantic versioning unsupported by SemVer v1.0.0 clients.

Well, nothing prevents us from throwing it into the product version. So, let’s do that. What the informational version actually ends up looking like however, needs to differ based on whether we’re dealing with a stable release vs a prerelease.

Luckily, we actually don’t actually have to do anything to achieve this, if we only end up setting the InformationalVersion when the build is a prerelease. This is because the defaullt behavior of InformationalVersion actually serves our purposes just fine.

Informational Version in Project File – Handled

<PropertyGroup>
  <InformationalVersion>$(VersionPrefix)-$(VersionSuffix)+$(BuildMetadata)</InformationalVersion>
</PropertyGroup>

This will end up looking similar to what our package versions look like, with the additional build metadata added to the end.

As far as where BuildMetadata comes from: we’ll cover this and other things now as we talk about putting it all together with some automation.

Version Workflow Automation

As you may have gleaned from reading the article, much of the additional version data (such as the build metadata and build number) need to be provided to MSBuild when building our project.

We’ll need two things to achieve this: a version file maintained in source control and a build script.

Version File

Although previous examples made it look like we’d be setting and updating the major, minor, and patch version numbers in the MSBuild project file, their entries in the PropertyGroup serve only to provide default values for these fields.

I wasn’t keen on requiring the MSBuild project file be changed every time I wanted to change one these fields — so instead we’ll have them maintained in a .json file.

This will serve dual purposes, as we will be tracking the distance between a build’s current commit and the last time this version file was updated in order to come up with the BuildNumber property.

version.json

{
  "$schema": "https://badecho.com/version.schema.json",
  "majorVersion": 1,
  "minorVersion": 0,
  "patchVersion": 7,
  "prereleaseId": "beta"
}

This will then be read and processed by the build script we’ll be going over next.

Project Build Script

A PowerShell script was devised that reads the version file shown above and sets the appropriate MSBuild properties when building our solution based on both the version file and some optional parameters provided to said script.

These optional parameters include the short hash of the current commit causing the build to be generated, as well as the calculated version distance between the current commit and the commit where version.json was last modified to be used as a build number.

While the build script itself could probably figure out the version distance, I wanted to make it the responsibility of the workflow action YAML file that gets invoked by the CI environment instead.

For Bad Echo technologies, which is hosted on GitHub, one can see how that works by taking a look at the repository’s push.yml action file.

You may have caught that the optional parameters we just went over pertain to matters we only care about for prerelease builds. This is correct, and therefore when these parameters are not provided, the resulting build is a stable release.

build.ps1

# Builds the Bad Echo solution.

param (
	[string]$CommitId,
	[string]$VersionDistance
)

function Execute([scriptblock]$command) {
	& $command
	if ($lastexitcode -ne 0) {
		throw ("Build command errored with exit code: " + $lastexitcode)
	}
}

function AppendCommand([string]$command, [string]$commandSuffix){
	return [ScriptBlock]::Create($command + $commandSuffix)
}

$artifacts = ".\artifacts"

if (Test-Path $artifacts) {
	Remove-Item $artifacts -Force -Recurse
}

$versionSettings = Get-Content version.json | ConvertFrom-Json
$majorVersion = $versionSettings[0].majorVersion
$minorVersion = $versionSettings[0].minorVersion
$patchVersion = $versionSettings[0].patchVersion

$buildCommand =  { & dotnet build -c Release -p:MajorVersion=$majorVersion -p:MinorVersion=$minorVersion -p:PatchVersion=$patchVersion }
$packCommand = { & dotnet pack -c Release -o $artifacts --no-build -p:MajorVersion=$majorVersion -p:MinorVersion=$minorVersion -p:PatchVersion=$patchVersion }

if($CommitId -and $VersionDistance) {	
	$prereleaseId = $versionSettings[0].prereleaseId
		
	$versionCommand = "-p:BuildMetadata=$CommitId -p:PrereleaseId=$prereleaseId -p:BuildNumber=$VersionDistance"

	$buildCommand = AppendCommand($buildCommand.ToString(), $versionCommand)
	$packCommand = AppendCommand($packCommand.ToString(), $versionCommand)
}

Execute { & dotnet clean -c Release }
Execute $buildCommand 
Execute { & dotnet test -c Release -r $artifacts --no-build -l trx --verbosity=normal }
Execute $packCommand

It’s a nice little system that I’ll probably improve on over time, but it gets the job done. Every time we do a commit and push, we’ll see some nice, properly versioned software projects being made and published to wherever:

Successfully created package 'D:\a\BadEcho\BadEcho\artifacts\BadEcho.Common.1.0.7-beta.14.nupkg'.
Successfully created package 'D:\a\BadEcho\BadEcho\artifacts\BadEcho.XmlConfiguration.1.0.7-beta.14.nupkg'.
Successfully created package 'D:\a\BadEcho\BadEcho\artifacts\BadEcho.Game.1.0.7-beta.14.nupkg'.
Successfully created package 'D:\a\BadEcho\BadEcho\artifacts\BadEcho.Presentation.1.0.7-beta.14.nupkg'.

The latest version of all the above code and their related versioning concepts can be found by referring to the Bad Echo technologies source repository, so check that out in case anything has happened to change since I wrote this.

I hope these collected notes on modern .NET versioning were useful to you, the reader! Fare thee well.