Deriving Version Information from Git for Incremental Builds

Apr 24, 2023
by:   Tim Stanley

If you want to generate a unique build number for each build in Azure Devops, Git, Team City, or other environments consistently, it can be challenging. This article is a summary of how to generate a unique build number when using git repositories, across multiple environments.

Microsoft Guidance

Microsoft has some guidance for versioning of libraries at https://learn.microsoft.com/en-ca/dotnet/standard/library-guidance/versioning#version-numbers [1].

Microsoft also has some guidance on package versioning of pre-releases at https://learn.microsoft.com/en-us/nuget/create-packages/prerelease-packages [2]

Some key points:

  • CONSIDER using SemVer 2.0.0 to version your NuGet package version.
  • CONSIDER only including a major version in the AssemblyVersion. aka 7.0.0.0. This helps reduce binding redirects.
  • DO use an AssemblyFileVersion the format Major.Minor.Build.Revision for file version.
  • Older versions of Visual Studio raise a build warning if this version doesn't follow the format Major.Minor.Build.Revision. The warning can be safely ignored.
  • And implied, CONSIDER making the FileVersion and PackageVersion unique for each build.

⚠️ Warning: If you push your packages to a NuGet server, Azure Artifacts, or GitHub Artifacts, the PackageVersion must be unique on each build.

📝 Note: If a PackageVersion is not unique on each build, the nuget cache must be cleared between builds or the restore will not pick up the new package..

📝 Note: New style CSPROJ files that use the dotnet SDK (Microsoft.Net.SDK) style projects do not require the AssemblyInfo.cs file. Old style CSPROJ files that use the MSBUILD xml format, use AssembyInfo.cs to control default version information.

📝 Note: Traditional Microsoft build tooling used versioning in the format: ....

Semantic Versioning 2.0

Semantic Versioning [3] provides a versioning scheme useful for libraries and packages.

Key points semantic versioning:

  • It effects a PUBLIC API, not internals, or dependencies.
  • {major}.{minor}.{match}-{prerelease}+{metadata}.
  • Major number changes for any public API breaking non backward compatible change.
  • Uses the following format
<valid semver> ::= <version core>
                 | <version core> "-" <pre-release>
                 | <version core> "+" <build>
                 | <version core> "-" <pre-release> "+" <build>

Semantic Versioning Examples:

  • Public Release Examples: 1.0.0, 7.0.2, 7.0.3.
  • Prerelease Examples: 1.0.0-alpha, 1.0.0-alpha.1, 1.0.0-0.3.7, 1.0.0-x.7.z.92, 1.0.0-x-y-z.--.
  • Metadata Examples: 1.0.0-alpha+001, 1.0.0+20130313144700, 1.0.0-beta+exp.sha.5114f85, 1.0.0+21AF26D3----117B344092BD.
  • Precedence of prerelease is less than without prerelease, Example: 1.0.0-alpha < 1.0.0.
  • Precedence of prerelease is alphabetic, Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0.

Versioning Tools

MinVersion

The MinVersion package is good if you want use semantic versioning 2.0 and want to embed the logic for the generation into MSBUILD properties for a project. The MinVersion CLI also provides a string that uses the same algorithm. Both the MinVersion package and the MinVersion CLI derive a version from a git tag in a special format (i.e. '7.0.2', 'ver-7.02')

https://www.nuget.org/packages/MinVer [4]

dotnet add package MinVer --version 4.3.0

https://www.nuget.org/packages/minver-cli [5]

dotnet tool install --global minver-cli --version 4.3.0

GitVersion

GitVersion [6] provides both a command line and MSBUILD package to set properties for a project during a build. GitVersion also has support for GitHub Actions and Azure Pipelines.

Git version provides some very powerful options for continuous builds and for builds from Visual Studio Like MinVersion, the GitVersion package is good if you want use semantic versioning 2.0 and want to embed the logic for the generation into MSBUILD properties for a project.

NerdBank.GitVersioning

Nerdbank.GitVersioning [7] adds precise, semver-compatible git commit information to every assembly, VSIX, NuGet and NPM package, and more. It implicitly supports all cloud build services and CI server software because it simply uses git itself and integrates naturally in MSBuild, gulp and other build scripts.

NBGV uses a version.json file along with settings in a Directory.Build.Props file to set versioning. This means it will work with dotnet command line calls, as well as will Visual Studio Builds (if the package is added to a project).

PowerShell Git Versioning

MinVersion and GitVersion provide some compelling and powerful options, particularly if there is a desire to use the NuGet package so that version information is consistent across Visual Studio.

I have releases that are only built using command line tools, but I want to be able to build from Linux, Mac, Windows, and in Team City, GitHub Actions, and Azure Pipelines without having to adopt a new versioning strategy or change significant build scripts.

I use one of three methods for building releases, all of which are command line related:

  1. A PowerShell command line that calls dotnet msbuild, invoked by Team City build agents.
  2. GitHub Actions.
  3. Azure Pipelines.

GitVersion and MinVersion provide the semantic versioning calculations to come up with an appropriate package versioning scheme. If we simply let the git tag used by those patterns be used without the calculations, then the package versioning scheme can be simplified.

I have simplified the MinVersion and GitVersion logic even further so that with a simple and appropriate tag, an appropriate RTM version, continuous release, or pre-release can be generated, all using the same construct, and all derived from the latest tag in a git repository.

To create a version, we need a few things:

  1. A git tag with a base version number with three digits {major}.{minor}.{build} or {major}.{minor}.{patch}.
  2. A count to be used for incremental build number {revision}.
  3. An indicator if this is a pre-release or not.

By placing a tag on a git commit that contains the first three digits of a version number, then we can then count how many git commits have been made since that version number. That git tag can also include pre-release information if needed.

Git Tags

By using a pattern prefix for a tag such as 'ver-*', then it makes it easy to search the tags in a git repository and find the latest in PowerShell. This pattern of tags for versioning is used by some other tools mentioned above.

Create a tag with a prefix matching the pattern 'ver-*'.

git tag -a ver-7.0.2 -m "set version to 7.0.2"

Find the last tag with the prefix 'ver-*' powershell

$tag = git tag --list 'ver-*' | Select-Object -Last 1

Now, find the count of how many git commits have been made since the above tag.

$hash = git show $tag --format="%H" -s | Select-Object -Last 1
$line = ("git show {0}..HEAD --format=""%H"" -s" -f $hash)
$items = Invoke-Expression $line
$count = $items.Count

📝 Note: Git push and Visual Studio git push do not normally push tags. If creating a local tag, you must use the --tags command to push tags to the cloud after the tags are created locally.

git push --tags

Tag Examples:

  1. RTM: 'ver-7.0.2'
  2. Pre-Release: 'ver-7.0.3-alpha', 'ver-7.0.3-alpha.0', 'ver-7.0.3-beta.0', 'ver-7.0.3-prerelease.0',
  3. Release Candidate: Pre-Release: 'ver-7.0.3-rc', 'ver-7.0.3-rc.0',
  4. Continuous Build: 'ver-7.0.2'

Get-GitVersion Function

A PowerShell function that given a git semver compatible tag with at least three digits for .. or .., will return a compatible array of Microsoft matching AssumblyVersion, FileVersion, and PackageVersion strings.

function  Get-GitVersion
{
	param ( [string] $VersionInput, [int] $Count )

	$revision = $count
	$ver = $VersionInput.Replace("ver-", "")

	$va = $ver.Split(".")
	$major = $va[0]
	$minor = $va[1]
	# Visual Studio / MSBUILD - {major}.{minor}.{build}.{revision}
	# SEMVER - {major}.{minor}.{match}-{prerelease}+{metadata}

	# $va[2] can contain a - if using semver
	[bool] $isSemVer = $va[2].Contains("-")
	if ($isSemVer)
	{
		$pr = $va[2].Split("-")
		$build = $pr[0]
	}
	else {
		$build = $va[2]
	}

	$av = ("{0}.{1}.{2}.{3}" -f $major,$minor,$build,$revision)
	$fv = ("{0}.{1}.{2}.{3}" -f $major,$minor,$build,$revision)
	if (($Count -gt 0) -or ($isSemVer))
	{
		$pv = ("{0}.{1}.{2}.{3}" -f $va[0], $va[1], $va[2], $revision)
	}
	else
	{
		$pv = ("{0}.{1}.{2}" -f $va[0], $va[1], $va[2])
	}

	$Versions = @($av, $fv, $pv)
	return $Versions
}

Calling the function is a matter of gluing the elements together:

$versions = Get-GitVersion -VersionInput  $tag -Count $count
  • $versions[0] contains AssemblyVersion
  • $versions[1] contains FileVersion
  • $versions[2] contains PackageVersion

Test Samples

Below are several tag examples and what will be returned by the AssemblyVersion, FileVersion, and PackageVersions.

ag = ver-0.0.0; Count = 0
AssemblyVersion = 0.0.0.0
FileVersion = 0.0.0.0
PackageVersion = 0.0.0
====================
Tag = ver-7.0.2; Count = 0
AssemblyVersion = 7.0.2.0
FileVersion = 7.0.2.0
PackageVersion = 7.0.2
====================
Tag = ver-7.0.2; Count = 3
AssemblyVersion = 7.0.2.3
FileVersion = 7.0.2.3
PackageVersion = 7.0.2.3
====================
Tag = ver-7.0.2.1; Count = 3
AssemblyVersion = 7.0.2.3
FileVersion = 7.0.2.3
PackageVersion = 7.0.2.3
====================
Tag = ver-7.0.2-dev; Count = 0
AssemblyVersion = 7.0.2.0
FileVersion = 7.0.2.0
PackageVersion = 7.0.2-dev.0
====================
Tag = ver-7.0.2-dev; Count = 5
AssemblyVersion = 7.0.2.5
FileVersion = 7.0.2.5
PackageVersion = 7.0.2-dev.5
====================
Tag = ver-7.0.3-dev; Count = 3
AssemblyVersion = 7.0.3.3
FileVersion = 7.0.3.3
PackageVersion = 7.0.3-dev.3
====================
Tag = ver-7.0.2-1; Count = 0
AssemblyVersion = 7.0.2.0
FileVersion = 7.0.2.0
PackageVersion = 7.0.2-1.0
====================
Tag = ver-7.0.2-1; Count = 2
AssemblyVersion = 7.0.2.2
FileVersion = 7.0.2.2
PackageVersion = 7.0.2-1.2
====================
minver --default-pre-release-identifiers 'dev' --tag-prefix "ver-"
MinVer: Using { Commit: 87103c9, Tag: 'ver-7.0.2', Version: 7.0.2, Height: 38 }.
MinVer: Calculated version 7.0.3-dev.38.
Tag = 7.0.3-dev; Count = 38
AssemblyVersion = 7.0.3.38
FileVersion = 7.0.3.38
PackageVersion = 7.0.3-dev.38
====================

Dotnet MSBUILD Parameters

Now we can pass those parameters for the version information to the build

$AssemblyVersion = $versions[0]
$FileVersion = $versions[1]
$PackageVersion = $versions[2]
dotnet msbuild $Solution -p:Configuration="Release" `
-p:AssemblyVersion=$($AssemblyVersion) `
-p:FileVersion=$($FileVersion) `
-p:Version=$($PackageVersion)

Package Reference Changes

To have a project reference both a wildcard version, and a wildcard pre-release version, an additional specifier is needed.

To Reference only a wildcard published version, use a single wildcard character.

<PackageReference Include="subsystem-1.libA" Version="7.*" />
<PackageReference Include="subsystem-1.libA" Version="[7.*, )" />

To Reference both a wildcard published version, and a pre-release version, use a two wildcard characters with a hyphen '-' separating them. This will pick up the latest package available (published or pre-release).

<PackageReference Include="subsystem-1.libA" Version="7.*-*" />
<PackageReference Include="subsystem-1.libA" Version="[7.*-*, )" />

CI/CD Counters

Several systems provide a unique build counter that is triggered on each build.

Team City

Team City provides a build.counter value that is incremented each time a build is triggered. It can be reset or set to a specific number to start with, but always increments on each build (success or failure).

Configuring General Settings | TeamCity On-Premises Documentation (jetbrains.com) [8]

GitHub Actions

GitHub Actions provides an environment variable GITHUB_RUN_NUMBER that increments on each build. There does not appear to be a way to control it. https://github.com/GitTools/actions [9]

When using GitHub Actions, the fetch-depth must be explicitly set so that the tags beyond the first commit will be available to the script.

steps:
      - uses: actions/checkout@v3
        with:
            fetch-depth: 0

GitHub Action Sample

A GitHub Action to invoke a powershell command

name: UX - Build and deploy to Azure Web App - tim-stanley-x

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3
        with:
            fetch-depth: 0

      - name: Set up .NET Core
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: '7.x'

      - name: PowerShell Build
        shell: pwsh
        run: |
          ./gitbuild.ps1 

      - name: Upload artifact for deployment job
        uses: actions/upload-artifact@v3
        with:
          name: .net-app
          path: ${{env.DOTNET_ROOT}}/myapp
          if-no-files-found: error

Azure Pipelines

Azure has a Build.BuildNumber and within it a $(Rev:r) number.

Build.BuildNumber https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml [10] $(Rev:r) https://learn.microsoft.com/en-us/azure/devops/pipelines/process/run-number?view=azure-devops&tabs=yaml [11]

The Rev: value is incremented on each new day if using the default value. When a build starts, if nothing else in the build number has changed, the Rev integer value is incremented by one.

When using Azure Pipelines, the fetchDepth must be explicitly set so that the tags beyond the first commit will be available to the script.

Azure Pipelines Sample

steps:
- checkout: self
  fetchDepth: 0  # the depth of commits to ask Git to fetch, 0 = full depth, so we get tags.
  fetchTags: true

- task: PowerShell@2
  displayName: 'Build'
  env:
     Build.Agent: "AZURE"
     Build.Configuration: "Release"
     Build.PublishArtifacts: "true"
     Build.Branch: $(Build.SourceBranchName)
     Build.Azure.BuildNumber: $(Build.BuildNumber)
  inputs:
    filePath: "$(System.DefaultWorkingDirectory)/gitbuild.ps1"
    failOnStderr: true
    showWarnings: true
    pwsh: true # Use PowerShell.Core

PowerShell Script Sample

The powershell script gitbuild.ps1 referenced by the GitHub Action and AzurePipeline

function  Get-GitVersion
{
	param ( [string] $VersionInput, [int] $Count )

	$revision = $count
	$ver = $VersionInput.Replace("ver-", "")

	$va = $ver.Split(".")
	$major = $va[0]
	$minor = $va[1]
	# Visual Studio / MSBUILD - {major}.{minor}.{build}.{revision}
	# SEMVER - {major}.{minor}.{match}-{prerelease}+{metadata}

	# $va[2] can contain a - if using semver
	[bool] $isSemVer = $va[2].Contains("-")
	if ($isSemVer)
	{
		$pr = $va[2].Split("-")
		$build = $pr[0]
	}
	else {
		$build = $va[2]
	}

	$av = ("{0}.{1}.{2}.{3}" -f $major,0,0,0)
	$fv = ("{0}.{1}.{2}.{3}" -f $major,$minor,$build,$revision)
	if (($Count -gt 0) -or ($isSemVer))
	{
		$pv = ("{0}.{1}.{2}.{3}" -f $va[0], $va[1], $va[2], $revision)
	}
	else
	{
		$pv = ("{0}.{1}.{2}" -f $va[0], $va[1], $va[2])
	}

	$Versions = @($av, $fv, $pv)
	return $Versions
}

################################
# Calculate the version numbers from git tags and commits
################################
# obtain the latest git tag
$tag = git tag --list ver-* | Select-Object -Last 1
[int] $count = 0
if ($null -ne $tag)
{
	# calculate how many commits since the tag
	$hash = git show $tag --format="%H" -s | Select-Object -Last 1
	$line = ("git show {0}..HEAD --format=""%H"" -s" -f $hash)
	$items = Invoke-Expression $line
	if ($null -ne $items)
	{
		$count = $items.Count
	}

	Write-Host ("Tag={0}" -f $tag)
	Write-Host ("Count={0}" -f $count)

	$versions = Get-GitVersion -VersionInput  $tag -Count $count
	$AssemblyVersion = $versions[0]
	$FileVersion = $versions[1]
	$PackageVersion = $versions[2]
}
else # No tag found, set some defaults
{
	Write-Host("No tag found, using defaults")
	$AssemblyVersion = "0.0.0.1"
	$FileVersion = "0.0.0.1"
	$PackageVersion = "0.0.0.1"
}

Write-Host ("FileVersion = {0}" -f $FileVersion)

$p = "./artifacts/logs/"
if (-Not (Test-Path -Path $p))
{
    $d = New-Item -ItemType Directory $p
}

################################
# Restore
################################
Write-Host ("dotnet restore")
dotnet restore ./Source/ContentEngine.Mvc.sln `

################################
# Build
################################
Write-Host ("dotnet msbuild")
dotnet msbuild -p:Configuration=Release ./Source/ContentEngine.Mvc.sln `
    -p:AssemblyVersion=$($AssemblyVersion) `
    -p:FileVersion=$($FileVersion) `
    -p:Version=$($PackageVersion) `
    -p:AllowedOutputExtensionsInPackageBuildOutputFolder=\""".dll;.exe;.winmd;.json;.pri;.xml\""" `
    -p:IncludeSymbols=true `
    -p:SymbolPackageFormat=snupkg `
    -nodeReuse:false `
    -bl:"./artifacts/logs/ContentEngine.Mvc.binlog" 

# set the output directory for publish
Write-Host ("env:DOTNET_ROOT = {0}" -f ${env:DOTNET_ROOT})
if ($null -ne ${env:DOTNET_ROOT}) {$d = ${env:DOTNET_ROOT}}
else {$d = $PWD}

################################
# Publish
################################
Write-Host ("dotnet publish")
dotnet publish --configuration Release ./Source/ContentEngine.Mvc/ContentEngine.Mvc.csproj -o $d/myapp --no-build

Update

Because git tags are associated with a changeset number and not a particular branch, this strategy works for a single branch. To handle multiple branches searching for a specific tag pattern unique within each branch. For example

  • branch: main, search for the tag ver-1.0.0
  • branch: releases/101, search for the tag ver-1.0.1
  • branch: releases/200, search for the tag ver-2.0.0

References

Related Items