Continuous Integration Build Numbers

A Build-Numbering System For Azure DevOps That Works With Most Git Branching Strategies

by JamesQMurphy | August 30, 2019

Continuous integration (CI) is great. You get timely feedback, you can ship code more often, you can tie in code coverage tools, etc. A good CI build system can produce a lot of metrics that tell you a lot about your codebase.

Do you know what it also can produce? A lot of builds.

This is why it's important for your CI builds to follow a logical numbering system. It's a must-have if you publish your software to repositories like NuGet or npm. But there are other good reasons to put some thought into your build numbering system:

  • Traceability. Traceability is the ability to match a particular version of software to the work that went into it. If you have a version number, you should be able to find its commit, who committed it, what features were added, what issues were fixed, etc.
  • Readiness. By readiness, I mean how production-ready the code is. A version number that includes "beta" indicates that this is not the final version of the code.

Wait a minute... did I just say version number? I meant build number... or did I?

Build Numbers vs. Version Numbers

Let me clarify what I mean when I use the terms build numbers and version numbers:

  • Build numbers are identifiers for a particular build -- the actual attempt to compile and package whatever it is you are building.
  • Version numbers are identifiers for a particular result, or artifact, of a build. They apply to the thing you are building -- usually the software, but they can apply to things like specifications, documents, or just about anything else.

There's no hard-and-fast rule that says the two have to match. But if you can make them match, you should. Not only does it reduce confusion, but it makes traceability much easier. And some build and deployment tools assume they match. I always strive to make them match anyway, and the case is no different for this website. So throughout the rest of this post, I'll use the terms build number and version number interchangeably.

Semantic Versioning

The default build numbering scheme in Azure DevOps is the date-based YYYYMMDD.n pattern. While this does ensure that the build numbers are unique and that they are ordered chronologically, it doesn't look anything like a version number that I've seen in the real world. Most of us are familiar with the traditional "1.0", "2.0", etc. numbering system, which was eventually codified into a formal specification known as Semantic Versioning (SemVer).

If you haven't read the SemVer specification, it will help to have a quick look at it. The parts of a SemVer-compliant version number are:

  • Major (number)
  • Minor (number)
  • Patch (number)
  • Optional Pre-release tag (text and numbers)
  • Optional build metadata (almost anything)

The Semantic Versioning specification defines guidelines for when to increment the Major, Minor, and Patch portions of a version number. The specifics aren't relevant to this discussion, but the important point is that they should be intentionally specified; i.e., they should not simply be generated by your CI build system, but actually defined in the source code. A decision to increase the version number from 1.1.0 to 2.0.0 needs to be a deliberate action, subject to the same review as a code change.

We already have a logical place to define these version numbers; inside the build pipeline itself (azure-pipelines.yml). From here, the version number is readily accessible to the build pipeline:

variables:
  versionMajor: 1
  versionMinor: 2
  versionPatch: 3

You can change the build numbering pattern to use these variables by adding this line to azure-pipelines.yml:

name: $(versionMajor).$(versionMinor).$(versionPatch)$(Rev:.r)

Notice the last term ($(Rev:.r)); this term is added to ensure uniqueness.

Creating Unique Build Numbers

Each build should produce a unique build number, which is why most build systems include a build counter. In some build systems (like TeamCity), the counter is a simple incrementing value that never gets smaller unless you manually reset it. In Azure DevOps however, the counter is a smart counter. Azure DevOps will actually look at past build numbers and return a number that will make the build number unique. In the example above, if there already exists a build with a build number of 1.2.3.526, Azure DevOps will use .527 for $(Rev:.r), resulting in a build number of 1.2.3.527. If no builds exist with that pattern, Azure DevOps will replace $(Rev.r) with .1 for a build number of 1.2.3.1.

While this build numbering system is fine for simple cases, it was not flexible for my case. For starters, I wanted the first build number for a given version to simply be Major.Minor.Patch, without a fourth number. Also, I wanted the branch name present in the version number if the build did not come from the master branch. And although this will put the branch name in the build number:

name: $(versionMajor).$(versionMinor).$(versionPatch)-$(Build.SourceBranchName)$(Rev:.r)

it will do this for all branches, including master. And if that weren't enough, I always wanted the specific pre-release tag rc for GitFlow-style release branches. In other words:

  • Builds from the master branch should have no pre-release tag (e.g., 1.2.3).
  • Builds from release branches should have a pre-release tag of rc (e.g., 1.2.3-rc).
  • Builds from pull request branches should start with PR- and contain the pull request number.
  • Builds from any other branches should use the branch name as the pre-release tag. For example, a build from a branch named features/cool-feature would have a build number like 1.2.3-cool-feature.
  • The build counter would start at zero, and be excluded if it was zero.

The table below illustrates what I actually wanted, assuming that I had a build counter N that starts at zero instead of one. (M = Major, m = Minor, P = Patch, xx = Pull Request number)

Branch Pattern Example (N=0) Example (N=6)
master M.m.P.N 1.2.3 1.2.3.61
releases/* M.m.P-rc.N 1.2.3-rc 1.2.3-rc.6
pull/xx/merge PR-xx PR-37 PR-37.6
develop M.m.P-develop.N 1.2.3-develop 1.2.3-develop.6
features/cool-feature M.m.P-cool-feature.N 1.2.3-cool-feature 1.2.3-cool-feature.6

Setting the Build Number

Although I could get pretty close with Azure DevOps Pipeline Expressions, it still wasn't good enough. Instead, I wrote a PowerShell script named set-build-number.ps1 which sets the build number according to the rules listed above. The complete file is in the GitHub repository, but I'll review the relevant parts here.

The first thing that the script does is determine which pattern to use, based on the branch name. You can see that the variables I defined in azure-pipelines.yml (versionMajor, etc.) are available as environment variables ($env:VERSIONMAJOR). Note that the script also accounts for pull request builds:

$baseBuildNumber = switch -regex ($env:BUILD_SOURCEBRANCH) {
    
    'refs/heads/master'
        { "$($env:VERSIONMAJOR).$($env:VERSIONMINOR).$($env:VERSIONPATCH)" }
    
    'refs/heads/releases/*'
        { "$($env:VERSIONMAJOR).$($env:VERSIONMINOR).$($env:VERSIONPATCH)-rc" }

    'refs/pull/(\d+)/merge'
        { "PR-$($Matches[1])" }

    default
        { "$($env:VERSIONMAJOR).$($env:VERSIONMINOR).$($env:VERSIONPATCH)-$($env:BUILD_SOURCEBRANCHNAME)" }
}

The next step is to determine the value of N, which requires us to query the build system and look at all the previous build numbers. I wrote a helper function Invoke-AzureDevOpsWebApi to actually make the call to Azure DevOps; the function is available in the file common-functions.ps1. In this case, I call the Builds API function to retrieve the list of builds, and then select just the build numbers.

# Retrieve builds for this definition that match the pattern
$previousBuildNumbers = Invoke-AzureDevOpsWebApi 'build/builds' -Version '5.0' `
    -QueryString "definitions=$($env:SYSTEM_DEFINITIONID)&buildNumber=$baseBuildNumber*" |
    Select-Object -ExpandProperty Value |
    Select-Object -ExpandProperty buildNumber

Once I have a list of build numbers that use the same pattern, I determine the value of N by looking for the largest value of N previously used, keeping in mind that if zero had been used, it would not explicitly be there.

$N = 0
if (($previousBuildNumbers -ne $null) -and (@($previousBuildNumbers).Count -gt 0)) {
    
    @($previousBuildNumbers) | ForEach-Object {
        Write-Output " $_"   # this step is for logging
        if ($_ -eq $baseBuildNumber) {
            $N = 1
        }
    }

    @($previousBuildNumbers) | Where-Object {$_ -match "$baseBuildNumber\.\d+`$" } | ForEach-Object {
        $split = $_ -split '\.'
        $previousN = [Int32]::Parse($split[($split.Length - 1)])
        if ($previousN -ge $N) {
            $N = $previousN + 1
        }
    }
}

Once I have the value for N, I can change the version number by emitting an Azure DevOps Logging Command2:

if ($N -eq 0) {
    $newBuildNumber = $baseBuildNumber
}
else {
    $newBuildNumber = "$baseBuildNumber.$N"
}

Write-AzureDevOpsLoggingCommand -Area build -Action updatebuildnumber -Message $newBuildNumber

I have another helper function named Write-AzureDevOpsLoggingCommand (also available in common-functions.ps1) to output the logging command, but all it does is write something like this to standard output:

##vso[build.updatebuildnumber]1.2.3-cool-feature.6

And that's all it takes to update the build number.

There's a Reason Why It's Called A Version Number

Semantic Versioning also defines version precendence, i.e., which version comes before which. This is a crucial factor for packaging systems like NuGet and npm, which need to know which version is the "latest" version. It also comes into play with certain deployment systems like Octopus Deploy. I've seen cases where a package received a "wrong" version number like 1234.0.0.0, instead of 1.2.3.4. Unless you are able to purge the "wrong" package from the repository, you would need to give your future releases even higher version numbers (like 1234.1.0.0 or even 1235.0.0.0) to prevent the "wrong" package from being selected as the "latest."

To prevent this from happening, I added a build validation step that uses the following rules, based on whichever branch is being built:

  1. For the master branch itself, always allow the build.
  2. If the branch is not the master branch, but is equivalent to master, allow the build. (If it were possible, I would prevent the build from being triggered in the first place. But since this is usually not possible, the script just passes validation and allows the build.)
  3. If the branch is one or more commits behind master, fail the build since it needs to be updated with the commits from master.
  4. If the branch is zero commits behind, and at least one commit ahead of master, then check the version number. If it is less than or equal to master's version number, then fail the build.

Validating the Build Branch

The script validate-build-against-branch.ps1 takes care of the build validation rules I laid out in the previous section. To determine how many commits behind and ahead another branch (stored in the variable $CompareBranch, in case I don't actually use master), I used the following bit of script based on this StackOverflow answer. One important thing to note is that a call like git fetch origin master will fetch the remote branch named master, but will not create a local branch named master. So instead, I refer to the tip of the fetched branch using the git ref FETCH_HEAD:

$thisBranchCommit = & git log -1 --format="%H"

# $CompareBranch defaults to 'master'
& git fetch origin $CompareBranch

$behindAhead = & git rev-list --left-right --count "FETCH_HEAD...$thisBranchCommit"
if ($behindAhead -match '(\d+)\s+(\d+)') {
    $commitsBehind = [int]::Parse($Matches[1])
    $commitsAhead = [int]::Parse($Matches[2])
}
else {
    Write-AzureDevOpsBuildError "Could not parse `$behindAhead ($behindAhead)"
    exit 1
}
Write-Output "This branch is $commitsBehind commits behind and $commitsAhead commits ahead of branch $CompareBranch"

Once I have the number of commits behind and ahead, it is trivial to implement the first three rules. (The actual script file doesn't do it exactly this way, but the code below produces the same results.)

if ($commitsBehind -gt 0) {
    Write-AzureDevOpsBuildError "This branch must be 0 commits behind branch $CompareBranch; validation failed."
    Write-AzureDevOpsBuildError "Perform a git merge from branch $CompareBranch into this branch."
    exit 1
}
if ($commitsAhead -eq 0) {
    Write-AzureDevOpsBuildError "This branch is equivalent to $CompareBranch; validation successful."
    exit 0
}

To implement Rule #4, we need to compare the version number of this branch with the version number in the $CompareBranch, which means we need to read the azure-pipelines.yml file from the $CompareBranch branch. Because I wanted to be flexible, I wrote a separate function to parse the version information from the contents of a file. This way, if I ever decided to store version information in a different file (perhaps a JSON-formatted file), I would only have to rewrite the code once:

function Get-VersionInfoFromFileContents {
    param(
        [string[]] $FileContents
    )

    $versionMajor = -1
    $versionMinor = -1
    $versionPatch = -1
    $FileContents | Where-Object { $_ -match '\s+version(Major|Minor|Patch):\s+(\d+)' } | ForEach-Object {
        switch($Matches[1]) {
            'Major' { $versionMajor = [int]::Parse($Matches[2]) }
            'Minor' { $versionMinor = [int]::Parse($Matches[2]) }
            'Patch' { $versionPatch = [int]::Parse($Matches[2]) }
        }
    }

    return New-Object PSObject -Property @{Major=$versionMajor; Minor=$versionMinor; Patch=$versionPatch}
}

Once I had this function, I could call it separately for both the current branch and the $CompareBranch version:

# $VersionFile defaults to 'azure-pipelines.xml'

# Get this branch's version
$contents = Get-Content $VersionFile
$thisBranchVersion = Get-VersionInfoFromFileContents $contents
Write-Output "This branch version is $($thisBranchVersion.Major).$($thisBranchVersion.Minor).$($thisBranchVersion.Patch)"

# Get compare branch's version
$contents = & git --no-pager show "FETCH_HEAD:$VersionFile"
$compareBranchVersion = Get-VersionInfoFromFileContents $contents
Write-Output "Branch $CompareBranch version is $($compareBranchVersion.Major).$($compareBranchVersion.Minor).$($compareBranchVersion.Patch)"

All that is left is comparing the two version numbers:

# Check if this branch's version is greater than the compare branch's version
$thisBranchVersionGreater = ($thisBranchVersion.Major -gt $compareBranchVersion.Major) -or
                            (($thisBranchVersion.Major -eq $compareBranchVersion.Major) -and ($thisBranchVersion.Minor -gt $compareBranchVersion.Minor)) -or
                            (($thisBranchVersion.Major -eq $compareBranchVersion.Major) -and ($thisBranchVersion.Minor -eq $compareBranchVersion.Minor) -and ($thisBranchVersion.Patch -gt $compareBranchVersion.Patch))
if (-not $thisBranchVersionGreater) {
    Write-AzureDevOpsBuildError "This branch must have a higher version number than branch $CompareBranch; validation failed."
    Write-AzureDevOpsBuildError "Increase the version number on this branch to something higher than $($compareBranchVersion.Major).$($compareBranchVersion.Minor).$($compareBranchVersion.Patch)."
    exit 1
}

Write-Output "This branch successfully validated against branch $CompareBranch."
exit 0

Wrapping Up

I've used this general approach for build numbers on several occasions, and it fits well with almost any branch merging strategy. That said, there will very likely be additional challenges for your specific build situation. For example, if you this build numbering scheme for a process that builds a NuGet package, it will fail for pull request builds (since PR-123 is not a valid SemVer number). However, using a little bit of scripting, you can easily tailor this process to fit your specific needs.


  1. Strictly speaking, 1.2.3.6 is not a SemVer-compliant version number. However, in all of the build systems I have encountered, a fourth number works exactly as one would expect. And based on the rules I define later, a fourth number in a production version number would only happen if somebody committed a change directly to master, which rarely, if ever, happens. But if it did happen (and there may be cases where it needs to happen), the build numbering rules still work and still allow it.

  2. Microsoft finally included these commands in their formal documentation. For years, the only documentation was in their azure-pipelines-tasks repo.