Deriving Versions from Git: Comparing MinVer, Nerdbank.GitVersioning and GitVersion
There are three ways to derive versions from Git: MinVer looks only at tags, Nerdbank.GitVersioning combines version.json with git height, and GitVersion calculates by branch type. We compare all three on Bookstore.Contracts with the same scenario and real output, and show the tag prefix, AssemblyVersion defaults, GitVersion 6's config changes and the shallow clone trap that requires fetch-depth: 0 in CI.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), Git 2.54, MinVer 8.0.0, Nerdbank.GitVersioning 3.10.94, GitVersion 6.8.2 and actionlint 1.7.12. The workflow was validated with actionlint; it was not run on GitHub.
Guess First
In the Bookstore.Contracts package of Bookstore, the online bookstore used as the shared example across this site's series, we stopped writing the version by hand and added MinVer. MinVer is a small MSBuild package that calculates the version from Git tags:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="8.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>
The repository has a v1.0.0 tag, and two commits were made after it. We create the package:
$ git log --oneline --decorate
669bffa (HEAD -> main) feat: add publisher
eda29a1 fix: trim isbn
802d782 (tag: v1.0.0) feat: book dto
$ dotnet pack src/Bookstore.Contracts -o out
What is the package called? 1.0.0, a pre-release starting with 1.0.1, or something else?
Successfully created package '.../out/Bookstore.Contracts.0.0.0-alpha.0.2.nupkg'.
MinVer never saw the tag. With default settings it expects the tag name to be a SemVer version as is; v1.0.0 starts with a v, so it is not a valid version. Having found no tag, it uses the default 0.0.0-alpha.0 and appends the number of commits since the first commit. No error, no warning; just a wrong package name. In this post we put three tools that derive the version from Git history side by side in the same scenario and look at where silent differences like this come from.
Three Approaches
All three tools fill the same properties: Version, PackageVersion, AssemblyVersion, FileVersion, InformationalVersion. In the previous post we supplied them by hand or from the command line; now an MSBuild target looks at Git during the build and calculates them. They differ in where the version information lives:
- MinVer: the tag is the only source of the version. A tagged commit gets that version, later commits become pre-releases of the next patch. No extra file in the repository.
- Nerdbank.GitVersioning (NBGV): major and minor are written in a
version.jsonfile; the patch is the number of commits since that file last changed, the git height. No tags needed. - GitVersion: starts from the latest tag, but decides how to increment based on the branch type and the rules in
GitVersion.yml. The most flexible option, and the one that needs the most configuration.
To make the comparison fair we played the same scenario with all three: first commit, a v1.0.0 tag, two commits, a feature branch and a merge, then the next release. At each step we read the four properties with this command and put the results into a table:
dotnet build src/Bookstore.Contracts -t:Build \
-getProperty:Version -getProperty:AssemblyVersion \
-getProperty:FileVersion -getProperty:InformationalVersion
MinVer: Tags Only
The fix for the problem in the guess section is one line: tell MinVer the tag prefix.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<MinVerTagPrefix>v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="8.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>
The results of the same scenario:
step Version AssemblyVersion FileVersion InformationalVersion
first commit 0.0.0-alpha.0 0.0.0.0 0.0.0.0 0.0.0-alpha.0+acc482c1766a…
tag v1.0.0 1.0.0 1.0.0.0 1.0.0.0 1.0.0+acc482c1766a…
2 commits later 1.0.1-alpha.0.2 1.0.0.0 1.0.1.0 1.0.1-alpha.0.2+30568b6a5f56…
feature/stock-dto 1.0.1-alpha.0.3 1.0.0.0 1.0.1.0 1.0.1-alpha.0.3+f7f235a553e9…
main, after merge 1.0.1-alpha.0.3 1.0.0.0 1.0.1.0 1.0.1-alpha.0.3+abf0b06cb6ef…
tag v1.1.0-rc.1 1.1.0-rc.1 1.0.0.0 1.1.0.0 1.1.0-rc.1+abf0b06cb6ef…
1 commit after rc 1.1.0-rc.1.1 1.0.0.0 1.1.0.0 1.1.0-rc.1.1+8925957e4404…
tag v1.1.0 1.1.0 1.0.0.0 1.1.0.0 1.1.0+8925957e4404…
The rules are simple. A tagged commit gets the tag's version. Commits after a stable release, which MinVer calls RTM, become pre-releases of the next patch: 1.0.1-alpha.0, followed by the number of commits since the tag (the height). After a pre-release tag (v1.1.0-rc.1) the label is kept and only the height is appended: 1.1.0-rc.1.1. You can switch the incremented part to minor or major with MinVerAutoIncrement, and replace alpha.0 with MinVerDefaultPreReleaseIdentifiers.
Watch two details. The first is AssemblyVersion: MinVer always sets it to Major.0.0.0. The rule we set up by hand in the previous post comes for free. The second is the two rows in the middle of the table: the commit on the feature branch and the merge commit on main are different commits, but both get version 1.0.1-alpha.0.3. MinVer does not take the branch name into account. If you try to publish packages with the same version number from two branches, the feed rejects the second one; that is why teams using MinVer usually publish pre-release packages from main only.
We saw that since .NET 8 the SDK appends the commit SHA to InformationalVersion; the value MinVer calculates is still followed by +sha. MinVer also has a CLI tool for non-.NET projects (minver-cli); it helps when you want to give the same version to an npm package in the same repository.
Nerdbank.GitVersioning: version.json and Git Height
NBGV looks at it differently: the fixed part of the version lives in a file in the repository, and every commit produces a new patch on its own.
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "1.0",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/v\\d+(?:\\.\\d+)?$"
],
"nugetPackageVersion": {
"semVer": 2
}
}
"version": "1.0" gives the major and minor; the third number is the git height. publicReleaseRefSpec defines which refs count as "public release" builds; builds from any other ref get the commit id added to their version. The same scenario, this time without tags:
step Version AssemblyVersion FileVersion
first commit 1.0.1 1.0.0.0 1.0.1.31635
2 commits later 1.0.3 1.0.0.0 1.0.3.11548
feature/stock-dto 1.0.4-g75ee34d545 1.0.0.0 1.0.4.30190
main, after merge 1.0.5 1.0.0.0 1.0.5.49450
Every commit on main gets a releasable version without a pre-release label: 1.0.3, 1.0.5. The feature branch is not a public ref, so it gets the -g75ee34d545 suffix; that removes the chance of two branches producing the same version. The fourth part of FileVersion is a number derived from the commit id. AssemblyVersion defaults to Major.Minor.0.0; it changes one step more often than MinVer's. To see the value on the command line, nbgv as a local tool:
$ dotnet tool install nbgv --version 3.10.94
$ dotnet nbgv get-version
Version: 1.0.5.49450
AssemblyVersion: 1.0.0.0
AssemblyInformationalVersion: 1.0.5+c12a1f0d5a
NuGetPackageVersion: 1.0.5
NpmPackageVersion: 1.0.5
Opening a release branch is done with the tool as well. Set version.json to 1.1-rc and run prepare-release; NBGV opens a v1.1 branch, turns the version there into a stable 1.1, and moves main to 1.2-alpha:
$ dotnet nbgv prepare-release
v1.1 branch now tracks v1.1 stabilization and release.
main branch now tracks v1.2-alpha development.
step Version AssemblyVersion
main, "1.1-rc" 1.1.1-rc 1.1.0.0
main after prepare 1.2.2-alpha 1.2.0.0
v1.1 branch 1.1.2 1.1.0.0
v1.1 + 1 fix 1.1.3 1.1.0.0
$ dotnet nbgv tag
Remember to push to a remote: git push origin v1.1.3
When version in the file changes, the height resets; that is why we see 1.2.2-alpha on main and 1.1.2 on the release branch. NBGV's tag command tags the current commit with the calculated version but does not push it; in NBGV the tag is a result, not a source.
GitVersion 6: Calculating by Branch
GitVersion takes the version from the tag and increments it according to the branch type. In v6 the configuration starts from a workflow template (GitFlow/v1, GitHubFlow/v1, the experimental TrunkBased/preview1); without a file, GitFlow is assumed. For Bookstore's single-main-branch flow:
workflow: GitHubFlow/v1
assembly-versioning-scheme: Major
We added the assembly-versioning-scheme: Major line later. GitVersion's default is MajorMinorPatch; in the first attempt AssemblyVersion changed with every patch, inviting the binding problem from the previous post. The results of the scenario:
step Version AssemblyVersion FileVersion
tag v1.0.0 1.0.0 1.0.0.0 1.0.0.0
2 commits later 1.0.1-2 1.0.0.0 1.0.1.0
feature/stock-dto 1.0.1-stock-dto.1 1.0.0.0 1.0.1.0
main, after merge 1.0.1-4 1.0.0.0 1.0.1.0
"feat!: ..." commit 1.0.1-5 1.0.0.0 1.0.1.0
tag v1.1.0 1.1.0 1.0.0.0 1.1.0.0
"+semver: major" commit 2.0.0-1 2.0.0.0 2.0.0.0
In the GitHubFlow template main has an empty label; untagged commits get pre-releases such as 1.0.1-2 that carry the number of commits since the last tag. The feature branch gets the stock-dto label derived from the branch name. The CLI tool's output shows the details of the calculation:
$ dotnet tool install GitVersion.Tool --version 6.8.2
$ dotnet gitversion
{
"AssemblySemVer": "1.0.0.0",
"BranchName": "feature/stock-dto",
"FullSemVer": "1.0.1-stock-dto.1+3",
"InformationalVersion": "1.0.1-stock-dto.1+3.Branch.feature-stock-dto.Sha.fccfb26dc8c0ec6b132351c01c68b930953a5eb0",
"PreReleaseLabel": "stock-dto",
"SemVer": "1.0.1-stock-dto.1",
"VersionSourceDistance": 3,
"VersionSourceSemVer": "1.0.0",
...
}
Two rows in the table matter. The commit starting with feat!: did not change the version; by default GitVersion does not read Conventional Commits, it looks for its own +semver: major (or minor, patch) marker. The commit with that marker in its message moved the version to 2.0.0-1. It is cleaner to leave deriving versions from Conventional Commits to the release tools that are the topic of the next post.
For those moving from v5, the most important v6 changes: the branch pre-release setting was renamed from tag to label, is-mainline became is-main-branch, and the Mainline mode was removed. The deployment mode names also swapped places: the old ContinuousDelivery is now ManualDeployment, and the old ContinuousDeployment is now ContinuousDelivery. If you carry an old GitVersion.yml over as is, the same name can now mean a different behavior. Some output variables such as NuGetVersion and LegacySemVer were removed as well; pipeline scripts that use them get an empty value.
There is also a clash with the .NET 8 SDK. GitVersion writes its own build metadata into InformationalVersion, and the SDK appends the commit SHA once more:
1.0.1-2+Branch.main.Sha.1516ad602086b14bba49306f1bcd7b037796d901.1516ad602086b14bba49306f1bcd7b037796d901
In projects using GitVersion, turning off the SDK's addition is enough:
<PropertyGroup>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
The Shallow Clone Trap in CI and fetch-depth: 0
All three tools read Git history, but CI systems often fetch a copy without history for speed. actions/checkout fetches only the latest commit by default (fetch-depth: 1). We cloned a repository with one commit after the tag using --depth 1 and tried the three tools:
$ git clone --depth 1 <repository-url> contracts
# MinVer: no error, wrong version
# full clone: 1.1.1-alpha.0.1
Version: 0.0.0-alpha.0
# Nerdbank.GitVersioning: build fails
error MSB4018: Nerdbank.GitVersioning.GitException: Shallow clone lacks the objects required to calculate version height. Use full clones or clones with a history at least as deep as the last version height resetting change.
# GitVersion (with GITHUB_ACTIONS=true)
WARN [26-11-14 6:59:29:08] An error occurred:
Repository is a shallow clone. Git repositories must contain the full history. See https://gitversion.net/docs/reference/requirements#unshallow for more info.
The results show three different behaviors. NBGV stops the build; the problem is noticed right away. GitVersion rejects a shallow clone in a build server environment (here GITHUB_ACTIONS=true); run from a local terminal in the same folder, it calculated a wrong version from the visible part of history without an error. The most dangerous one is MinVer: without any warning it falls back to 0.0.0-alpha.0 and produces a package with that version. Turning on the detailed log shows why:
$ dotnet build src/Bookstore.Contracts -p:MinVerVerbosity=detailed
...
MinVer: 1 commits checked.
MinVer: No commit found with a valid SemVer 2.0 version prefixed with 'v'. Using default version 0.0.0-alpha.0.
MinVer: Using { Commit: bb7a151, Tag: '', Version: 0.0.0-alpha.0, Height: 0 }.
MinVer: Calculated version 0.0.0-alpha.0.
The fix is the same for all three: fetch the full history in CI. Bookstore.Contracts' CI workflow:
name: contracts-ci
on:
push:
branches: [main]
tags: ['contracts/v*']
pull_request:
permissions:
contents: read
jobs:
pack:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# MinVer needs tags and history
with:
fetch-depth: 0
filter: tree:0
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet pack src/Bookstore.Contracts -c Release -o artifacts
- uses: actions/upload-artifact@v7
with:
name: contracts-nupkg
path: artifacts/*.nupkg
filter: tree:0 is an optimization the MinVer documentation recommends: it fetches commits and tags, and downloads file trees only when needed. In Azure Pipelines the equivalent is fetchDepth: 0 on the checkout step. This workflow was validated with actionlint; it was not run on GitHub.
Fitting the Branching Strategy
Which tool fits depends largely on your branching strategy. In the "Teamwork with Git: Branching Strategies, Rulesets and Conventional Commits" post of the Git & GitHub series we compared three flows.
In trunk-based development main is the only long-lived branch, and a release ships by tagging a commit on main. MinVer is the most natural fit for this flow: tag it, and that is the version. NBGV works well too, but since it gives every commit on main a stable version, it is closer to an "every merge is a release" mindset.
In a flow with release branches (development on main, stabilization on a release/1.x or v1.1 branch), NBGV's prepare-release command does most of the work: it opens the branch and updates version.json on both sides. With MinVer you run the same flow by tagging on the release branch; MinVerMinimumMajorMinor is how you say main has moved on to the next minor.
In a flow like GitFlow, where develop, release, hotfix and feature branches each want different versioning behavior, GitVersion's branch-based rules start to make sense. The simpler the flow, the more the cost of that flexibility, understanding the configuration and carrying it across versions, stands out.
Decision Table
| Criterion | MinVer | Nerdbank.GitVersioning | GitVersion 6 |
|---|---|---|---|
| Version source | Tag | version.json + git height | Tag + branch rules |
| Extra file in the repository | None (csproj properties) | version.json | GitVersion.yml |
| Untagged main commit | 1.0.1-alpha.0.2 | 1.0.3 | 1.0.1-2 |
| Feature branch | Same format as main | -g + commit id | Label from branch name |
Default AssemblyVersion | Major.0.0.0 | Major.Minor.0.0 | Major.Minor.Patch.0 |
| Shallow clone | Silently wrong version | Build error | Error in CI |
| Several packages in a monorepo | Tag prefix per package | version.json per folder, pathFilters | Single version, needs extra setup |
| Learning cost | Low | Medium | High |
In Bookstore we work trunk-based, and the repository is a monorepo: the API, the web app and Contracts live together. We chose MinVer for Contracts and separated the tag prefix by package name; the API's app/v... tags do not affect Contracts' version:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- Version comes from tags such as contracts/v1.2.0 -->
<MinVerTagPrefix>contracts/v</MinVerTagPrefix>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MinVer" Version="8.0.0" PrivateAssets="all" />
</ItemGroup>
</Project>
$ git tag contracts/v1.0.0
$ dotnet pack src/Bookstore.Contracts -c Release -o artifacts
Successfully created package 'artifacts/Bookstore.Contracts.1.0.0.nupkg'.
For a single package and a simple flow, MinVer is enough. In a monorepo where several packages are versioned at their own pace and the height should only grow with changes in the relevant folder, NBGV's pathFilters feature fits better. If the team already uses GitFlow and wants different pre-release labels per branch type, GitVersion.
In Production
Produce the same version locally and in CI. A local clone usually has the full history, CI does not; that is why "I get 1.1.1-alpha.0.1, CI gets 0.0.0-alpha.0" is the most common complaint. Put fetch-depth: 0 into your workflow template, and during the first setup compare the version CI produces with your local result.
Use annotated tags and protect them. When the tag is the source of the version, deleting a tag and moving it to another commit makes it unclear which code a published package was built from. In the "Git Tags and Releases" post of the Git & GitHub series we discussed the consequences of moving tags; protecting release tags with a ruleset is a good safeguard.
Decide who publishes pre-release packages. Because MinVer can give two branches the same version, limit pre-release publishing to main; with NBGV and GitVersion feature branch versions are distinguishable, but every PR sending a package to the feed clutters it quickly.
Do not forget the .git folder in Docker builds. If you run dotnet pack or dotnet publish inside the image and .dockerignore excludes .git, the tools cannot see the history. Calculating the version outside and passing it as a build argument with -p:Version= is more predictable.
Common Pitfalls
Not telling the tool the tag prefix. The situation from the guess section: for MinVer the v1.0.0 tag does not exist. In NBGV tags are not the source anyway; GitVersion's default tag-prefix accepts the v. The same tags give different results in the three tools.
Tagging the wrong commit. A v1.1.0 placed on the commit before the release commit produces a 1.1.0 package without the last fix, and the right commit becomes 1.1.1-alpha.0.1. Check the commit with git log --oneline -1 before tagging; if possible, leave tagging to the release step in CI.
Mixing two tools in the same project. If an old version.json and the NBGV package are still in the repository when a developer adds MinVer, both targets write the same properties and the result depends on target order. Pick one tool and delete the other's package and file.
Assuming Conventional Commits change the version. A feat!: message alone does not bump the major in GitVersion, and MinVer and NBGV do not look at commit messages at all. Calculating the version from commit messages is the job of release tools.
Breaking code that parses InformationalVersion. When GitVersion and the SDK both write build metadata, the value contains two SHAs; code that treats everything after + as a single commit gets the wrong result.
A Small Experiment
★☆☆ In the MinVer project set MinVerAutoIncrement to minor, guess the version two commits after the tag, then check it with -getProperty:Version.
★★☆ Add "pathFilters": ["src/Bookstore.Contracts"] to NBGV. Does a commit that only changes a file under web/ increase Contracts' version? Run the same experiment with MinVer and explain the difference.
★★★ Clone one of your own repositories with --depth 1 and watch what your tool does. Then add a step to your CI workflow that stops the build if the produced version starts with 0.0.0.
We now take the version from Git; but the answer to "what changed in this version?" is still scattered across the commit log. In the next post we look at release automation that produces the version and a CHANGELOG from Conventional Commits, at release notes, and at a deprecation policy.