How to use GitVersion to get sensible versioning

Edit this page | 11 minute read

The challenges of versioning

It’s surprising to see that some teams are still struggling with the numbering of their DLLs, NuGet or NPM packages. Some rely on build numbers, some number them by hand by committing a version to source control, and some don’t even care about all of this.

Numbering your artifacts in such a way that they convey a certain level of stability is a matter of maturity that all teams should be adopting. And this goes hand-in-hand with a well-defined branching and merging strategy. In fact, combining the clarity of Semantic Versioning, a release strategy like GitFlow or GithubFlow and GitVersion gives you all of this practically for free.

To demonstrate this, let me show you a typical development workflow. So let’s assume you have been developing your newest marvel of a software package on a main or master branch and you’ve are tagging the first official release as 1.0.0.

> Git checkout main
> Git tag 1.0.0

Let’s start by installing GitVersion. If you’re on Windows and you use @Chocolatey, this is what brings magic to your machine:

> choco install gitversion.portable --yes

Now run gitversion from your console or bash. It’ll give you the following output:

> gitversion

{
"Major": 1,
"Minor": 0,
"Patch": 0,
"PreReleaseTag": "",
"PreReleaseTagWithDash": "",
"PreReleaseLabel": "",
"PreReleaseLabelWithDash": "",
"PreReleaseNumber": null,
"WeightedPreReleaseNumber": 60000,
"BuildMetaData": null,
"BuildMetaDataPadded": "",
"FullBuildMetaData": "Branch.main.Sha.50a50393d9cd0c9524fd3bb0bea1183c93a0f6c1",
"MajorMinorPatch": "1.0.0",
"SemVer": "1.0.0",
"LegacySemVer": "1.0.0",
"LegacySemVerPadded": "1.0.0",
"AssemblySemVer": "1.0.0.0",
"AssemblySemFileVer": "1.0.0.0",
"FullSemVer": "1.0.0",
"InformationalVersion": "1.0.0+Branch.main.Sha.50a50393d9cd0c9524fd3bb0bea1183c93a0f6c1",
"BranchName": "main",
"EscapedBranchName": "main",
"Sha": "50a50393d9cd0c9524fd3bb0bea1183c93a0f6c1",
"ShortSha": "50a5039",
"NuGetVersionV2": "1.0.0",
"NuGetVersion": "1.0.0",
"NuGetPreReleaseTagV2": "",
"NuGetPreReleaseTag": "",
"VersionSourceSha": "50a50393d9cd0c9524fd3bb0bea1183c93a0f6c1",
"CommitsSinceVersionSource": 0,
"CommitsSinceVersionSourcePadded": "0000",
"UncommittedChanges": 0,
"CommitDate": "2022-01-27"
}

There’s a lot of information in this JSON object, but the one that we will be looking at the most is FullSemVer. It shouldn’t surprise you that its value is 1.0.0.

The rest is meant to be used for versioning .NET assemblies, NuGet packages and NPM packages. I particularly like the InformationalVersion which even includes the git hash of the commit from which the number was calculated. A neat trick is that GitVersion understands and detects most build systems and automatically updates the build number reported to TeamCity, Azure Devops, AppVeyor and Github Actions.

A typical development workflow

Now assuming that your product is relatively new and you haven’t reached the confidence level that you can ship every commit to main immediately, we will adopt the GitFlow release strategy. This caters for an explicit stabilization phase before you release into production. So let’s create a dedicated branch to build the next version on and then re-run GitVersion.

> git checkout -b develop
> gitversion /showvariable FullSemVer

1.0.0

This may surprise you, but the fact is that the develop branch is still pointing at the same commit with that 1.0.0 tag. Let’s change some files and commit some changes.

> git add .
> git commit -m "Some changes on develop"
> gitversion /showvariable FullSemVer

1.1.0-alpha.1

GitVersion assumes that new functionality is developed on the develop branch, and by default, it assumes it will be backwards compatible. Since the 2nd number in a semantically versioned number represents exactly that, it automatically bumps that version. It also assumes that code on that branch represents the least stable version of the code-base, hence the alpha postfix. The .1 represents the number of commits since the most recent tag on that branch. So adding three more commits will give you:

> ….commit three more times
> gitversion /showvariable FullSemVer

1.1.0-alpha.4

Now imagine you are ready to stabilize your next release for production, but you want to continue developing other features for a future release. This is the point when you start off a release branch.

> git checkout -b release/1.1
> gitversion /showvariable FullSemVer

1.1.0-beta.1+0

Just like we saw on the develop branch, a part of the version increments with each commit on that branch. But since adding .0 would result in an ambiguous number, +0 is used. If you read the relevant section of the Semantic Versioning spec, you’ll find that the numbers after the + sign are irrelevant when comparing numbers. Also notice that release-1.1 and release/1.1 are equivalent. I prefer using a slash because some Git tools will use that to display the branches in a collapsible folder structure.

So after having committed some bugfixes to the release branch, you end up with this:

> gitversion /showvariable FullSemVer

1.1.0-beta.1+3

The default postfix is beta, but I usually change that to rc to emphasize the nature of that release. Anyway, let’s assume that it’s time to release a beta if you will.

> git tag 1.1.0-beta.1
> gitversion /showvariable FullSemVer

1.1.0-beta.1

No surprise here. You’ve basically told GitVersion that that last commit represents the first beta now. This is also normally the moment where you up-merge those fixes to develop. As soon as you commit a couple more changes to the release branch, this is what will happen.

> gitversion /showvariable FullSemVer

1.1.0-beta.2+6

So it understands that you’ve tagged a commit as beta 1, and assumes everything following that will eventually become beta 2. It does keep counting the commits since the last production release. So your QA team has confirmed the fixes after beta 1 are fine, have approved another beta and agreed to release.

> git tag 1.1.0-beta.2
> gitversion /showvariable FullSemVer

1.1.0-beta.2

> git checkout main
> git merge release/1.1
> gitversion /showvariable FullSemVer

1.1.0-beta.2

Because that beta tag is now visible from the main branch, that’s the version GitVersion will give you now. Normally, this is the point where you tag that same commit as 1.1.0.

> git tag 1.1.0
> gitversion /showvariable FullSemVer

1.1.0

But what does that mean for develop?

> git checkout develop
> gitversion /showvariable FullSemVer

1.2.0-alpha.8

Since that 1.1.0 tag is not visible from the develop branch, it might come as a surprise that GitVersion still bumped the minor version. That’s the result of GitVersion’s track-merge-target flag that is enabled in the default configuration. It knew it had to bump the minor part when you upmerged the release branch with the beta tags on it. And even if you merge main into develop (and you should), nothing will change:

> gitversion /showconfig

1.2.0-alpha.8

Now that the release has completed, it is time to delete the release branch.

> git branch -D release/1.1

What about hotfixes?

A couple of weeks later, you receive a bug report that affected the 1.1.0 release that is currently living on main. Let’s create a branch to repair that fix.

> git checkout main
> git branch -D hotfix/1.1
> gitversion /showvariable FullSemVer

1.1.0

Again, since nothing was committed yet, the number doesn’t change either. Now commit the code fix and try again:

> git commit -m "some changes"
> gitversion /showvariable FullSemVer

1.1.1-beta.1+1

See? GitVersion recognizes the significance of the hotfix prefix and automatically bumped the patch number of the version. And just like you saw with a release branch, the + symbol is used to track the commits since the last tag.

After thoroughly testing your fix, you’ll release that hotfix by merging it directly into main and then tagging the commit.

> git checkout main
> git merge hotfix/1.1
> git tag 1.1.1
> gitversion /showvariable FullSemVer

1.1.1

The cool thing about all of this is that successive hotfixes will automatically bump the version to 1.1.2-beta.x, even if you keep naming your hotfix branch hotfix/1.1. Oh, and don’t forget to up-merge all your fixes from main to develop as well. You don’t want to run the risk of fixing something on main that regresses after the next major and minor release.

Time to prepare for that next major release

Now say that the work that continued on the develop branch involves major functionality that requires breaking changes. How would you tell GitVersion that it should apply a major bump instead of the usual minor bump? Let’s look at the default configuration that GitVersion uses.

> gitversion /showconfig

assembly-versioning-scheme: MajorMinorPatch
assembly-file-versioning-scheme: MajorMinorPatch
mode: ContinuousDelivery
tag-prefix: '[vV]'
continuous-delivery-fallback-tag: ci
major-version-bump-message: '\+semver:\s?(breaking|major)'
minor-version-bump-message: '\+semver:\s?(feature|minor)'
patch-version-bump-message: '\+semver:\s?(fix|patch)'
no-bump-message: '\+semver:\s?(none|skip)'
legacy-semver-padding: 4
build-metadata-padding: 4
commits-since-version-source-padding: 4
tag-pre-release-weight: 60000
commit-message-incrementing: Enabled
branches:
develop:
    mode: ContinuousDeployment
    tag: alpha
    increment: Minor
    prevent-increment-of-merged-branch-version: false
    track-merge-target: true
    regex: ^dev(elop)?(ment)?$
    source-branches: []
    tracks-release-branches: true
    is-release-branch: false
    is-mainline: false
    pre-release-weight: 0
main:
    mode: ContinuousDelivery
    tag: ''
    increment: Patch
    prevent-increment-of-merged-branch-version: true
    track-merge-target: false
    regex: ^master$|^main$
    source-branches:
    - develop
    - release
    tracks-release-branches: false
    is-release-branch: false
    is-mainline: true
    pre-release-weight: 55000
release:
    mode: ContinuousDelivery
    tag: beta
    increment: None
    prevent-increment-of-merged-branch-version: true
    track-merge-target: false
    regex: ^releases?[/-]
    source-branches:
    - develop
    - main
    - support
    - release
    tracks-release-branches: false
    is-release-branch: true
    is-mainline: false
    pre-release-weight: 30000
feature:
    mode: ContinuousDelivery
    tag: useBranchName
    increment: Inherit
    prevent-increment-of-merged-branch-version: false
    track-merge-target: false
    regex: ^features?[/-]
    source-branches:
    - develop
    - main
    - release
    - feature
    - support
    - hotfix
    tracks-release-branches: false
    is-release-branch: false
    is-mainline: false
    pre-release-weight: 30000
pull-request:
    mode: ContinuousDelivery
    tag: PullRequest
    increment: Inherit
    prevent-increment-of-merged-branch-version: false
    tag-number-pattern: '[/-](?<number>\d+)'
    track-merge-target: false
    regex: ^(pull|pull\-requests|pr)[/-]
    source-branches:
    - develop
    - main
    - release
    - feature
    - support
    - hotfix
    tracks-release-branches: false
    is-release-branch: false
    is-mainline: false
    pre-release-weight: 30000
hotfix:
    mode: ContinuousDelivery
    tag: beta
    increment: Patch
    prevent-increment-of-merged-branch-version: false
    track-merge-target: false
    regex: ^hotfix(es)?[/-]
    source-branches:
    - develop
    - main
    - support
    tracks-release-branches: false
    is-release-branch: false
    is-mainline: false
    pre-release-weight: 30000
support:
    mode: ContinuousDelivery
    tag: ''
    increment: Patch
    prevent-increment-of-merged-branch-version: true
    track-merge-target: false
    regex: ^support[/-]
    source-branches:
    - main
    tracks-release-branches: false
    is-release-branch: false
    is-mainline: true
    pre-release-weight: 55000
ignore:
sha: []
commit-date-format: yyyy-MM-dd
merge-message-formats: {}
update-build-number: true

Looking at the configuration, you should be able to deduce that you can add a +semver: breaking or +semver: major to the commit message to bump the version. But there are other ways as well such as adding a next-version field to the gitversion.yaml file in the root of your repo, or just using a tag to force a number. Alternatively, you could decide to postpone bumping the version until you create a release branch with the appropriate number:

> git checkout develop
> gitversion /showvariable FullSemVer

1.2.0-alpha.8

> git checkout -b release/2.0
> gitversion /showvariable FullSemVer

2.0.0-beta.1+0

So even though GitVersion may number your develop branch as 1.2.x, as soon as you create a release branch with a different number, it’ll use that one.

Let’s finish the major release.

> git checkout main
> git merge release/2.0
> git tag 2.0.0
> gitversion /showvariable FullSemVer

2.0.0

What about supporting the previous major version

As we like to follow semantic versioning, the implied consequence of bumping the major part of the version to 2 means that existing users of the package cannot update to that version without some kind of rework, significantly changed behavior, or new technical prerequisites. To keep supporting those users, you’ll need another branch:

> git checkout 1.1.1
> git checkout -b support/1.x
> gitversion /showvariable FullSemVer

1.1.1

No surprise there. As long as there are no fixes needed on a support branch, that branch points to the last 1.x.x tag. But as soon as you start pushing commits to that branch, either directly or through a hotfix branch, the number will increase.

> git commit 
> gitversion /showvariable FullSemVer

1.1.2+1

The rest works exactly as on main. So in essence, a support branch is nothing like an alternative reality of main that starts off when your main receives breaking changes. But don’t forget to upmerge those fixes back to main, any release branches and develop. And if you no longer have to maintain version 1, delete the support branch.

How do you use GitVersion in your build script

As you saw in the beginning of this article, GitVersion is available through Chocolatey, but also as a dotnet global tool, Homebrew and through various other channels. The way I prefer to use it is as part of a Nuke build script. It comes out of the box with the default template and adds a field that gets initialized automatically:

[GitVersion(UpdateBuildNumber = true, Framework = "net6.0")]
readonly GitVersion GitVersion;

Nuke relies on the GitVersion.Tool being referenced as a <PackageDownload> element in the C# project file. With that, you can use the GitVersion field like this:

TargetCompile => _ => _
    .Executes(() =>
    {
        DotNetBuild(s => s
            .SetProjectFile(Solution)
            .SetAssemblyVersion(GitVersion.AssemblySemVer)
            .SetFileVersion(GitVersion.AssemblySemFileVer)
            .SetInformationalVersion(GitVersion.InformationalVersion));
    });

GitVersion tries to fetch information from other branches as well, so you need to make sure that your build server ensures a non-shallow clone of your git repository. You can find specific instructions for specific build servers here.

What if I force-push changes to my pull request?

This is an interesting case that I’ve encountered as well. If you follow my guidance on delivering a clean pull request for review, you’ll be doing lots of interactive rebases and force pushes. In fact, force pushing to a personal feature branch is the only place where you should use a force push. But that means that the number of commits in that PR won’t change even if the contents did. If you build NuGet or NPM packages or deploy environments from that PR, it might be desirable to still get a unique number per build.

In Fluent Assertions, which is build by Github Actions, I’ve solved this by adding the following target:

[Parameter("A branch specification such as develop or refs/pull/1775/merge")]
readonly string BranchSpec;

[Parameter("An incrementing build number as provided by the build engine")]
readonly string BuildNumber;

string SemVer;

Target CalculateNugetVersion => _ => _
    .Executes(() =>
    {
        SemVer = GitVersion.SemVer;
        if (IsPullRequest)
        {
            Serilog.Log.Information(
                "Branch spec {branchspec} is a pull request. Adding build number {buildnumber}",
                BranchSpec, BuildNumber);

            SemVer = string.Join('.', GitVersion.SemVer.Split('.').Take(3).Union(new [] { BuildNumber }));
        }

        Serilog.Log.Information("SemVer = {semver}", SemVer);
    });

bool IsPullRequest => BranchSpec != null && BranchSpec.Contains("pull", StringComparison.InvariantCultureIgnoreCase);

The BranchSpec and BuildNumber configuration settings are passed by GitHub through the following YAML definition:

- name: Run NUKE
    run: ./build.ps1
    env:
    BranchSpec: $
    BuildNumber: $

Then, in my Pack target I use the SemVer field instead of the one provided by the GitVersion.SemVer field.

Target Pack => _ => _
    .DependsOn(ApiChecks)
    .DependsOn(TestFrameworks)
    .DependsOn(UnitTests)
    .DependsOn(CalculateNugetVersion)
    .Executes(() =>
    {
        DotNetPack(s => s
            .SetProject(Solution.Core.FluentAssertions)
            .SetOutputDirectory(ArtifactsDirectory)
            .SetConfiguration("Release")
            .SetVersion(SemVer));
    });

Given a customized GitVersion.yaml file in that repo, this gives me numbers like 6.0-pr1807.282 where 1807 is the PR number and 282 the build number.

Wrap-up

So what do you think? Did I convince you to mature your build process by introducing GitVersion? If not, what then are you planning to use? Let me know by commenting below. Oh, and follow me at @ddoomen to get regular updates on my everlasting quest for better solutions

Leave a Comment