
Versioning stays invisible right up to the point when it fails. A package has to be reissued, a support case depends on one exact binary, or staging and production turn out to run artifacts from the same commit that do not quite match. At that moment the version number stops being release-note decoration and becomes engineering data.
That is the context in which versioning needs to be discussed in modern .NET repositories. The label on a NuGet package is only one piece of the picture. The build also produces assembly metadata, file metadata, package metadata and runtime-visible information that later has to answer a very practical question: what source state produced this artifact?
The phrase “idempotent artifacts” is often used for this, although it usually conflates three different goals:
- one source state maps to one version identity
- the same inputs build reproducibly
- release artifacts are built once and promoted unchanged
Those goals reinforce each other, but they are not the same thing. A sound versioning setup in .NET should make room for all three.
A .NET version number serves more than one consumer
The trouble starts when a repository tries to make one number do every job.
For a library, the version is a compatibility contract for package consumers. For an application, it is an operational identifier that support and deployment tooling need to trust. At build time, it also becomes assembly and package metadata that has to remain internally consistent.
That is why modern .NET projects surface several version values at once:
VersionorPackageVersionAssemblyVersionFileVersionInformationalVersion
These are related, but they are not interchangeable.
In practice, a durable default often looks like this:
VersionorPackageVersioncommunicates the release identityAssemblyVersionchanges conservatively, especially for shared librariesFileVersioncan move with each shipped buildInformationalVersioncarries the richest traceability data
That split becomes important as soon as a repository ships more than one release. A package may move quickly while binary compatibility policy stays intentionally stable.
Why the old four-part model is no longer enough on its own
The classic four-part format, major.minor.build.revision, still matters because .NET and Windows both use numeric assembly and file versions. The problem is not that the format is obsolete. The problem is that it does not express release intent particularly well when it is also expected to serve as package version, human-facing product version and support identifier.
That mismatch becomes obvious in long-lived repositories that reuse the same number everywhere:
- CLR binding identity
- Windows file properties
- NuGet package version
- release notes
- runtime diagnostics
Those surfaces need different things. AssemblyVersion cares about binary identity. A package feed cares about SemVer sorting. Support tooling wants something that can be traced back to a commit. Forcing a single four-part number across all of them usually creates either weak compatibility signaling or weak traceability.
The old wildcard pattern illustrates the same problem from another angle:
1[assembly: AssemblyVersion("1.0.*")]
or:
1[assembly: AssemblyFileVersion("1.0.*")]
That style was convenient when a local compiler-generated build number felt sufficient. In a CI/CD workflow it ages badly, because the generated version is tied to build time rather than repository state. Rebuilding the same commit later produces a different version even if the code is identical.
Modern auto increment is still useful. It just needs to be driven by source history or explicit release policy instead of by the clock.
One source of truth matters more than the exact tool
The first meaningful decision is structural: version state has to come from one place, and the build has to consume it mechanically.
For small repositories a manual MSBuild-based model can be good enough. A repository-wide Directory.Build.props already eliminates a surprising amount of drift:
1<Project>
2 <PropertyGroup>
3 <VersionPrefix>2.4.0</VersionPrefix>
4 <VersionSuffix Condition="'$(VersionSuffix)' == ''">
5 preview.3
6 </VersionSuffix>
7 <Version Condition="'$(VersionSuffix)' != ''">
8 $(VersionPrefix)-$(VersionSuffix)
9 </Version>
10 <Version Condition="'$(VersionSuffix)' == ''">
11 $(VersionPrefix)
12 </Version>
13
14 <AssemblyVersion>2.0.0.0</AssemblyVersion>
15 <FileVersion>2.4.0.0</FileVersion>
16 <InformationalVersion>$(Version)</InformationalVersion>
17
18 <Deterministic>true</Deterministic>
19 <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">
20 true
21 </ContinuousIntegrationBuild>
22 <PublishRepositoryUrl>true</PublishRepositoryUrl>
23 <EmbedUntrackedSources>true</EmbedUntrackedSources>
24 </PropertyGroup>
25</Project>
That baseline is already a substantial improvement over hand-edited AssemblyInfo.cs files spread across a solution. It centralizes intent and makes mismatched metadata less likely.
Its weakness is not syntax. Its weakness is that the build identity still lives only partly in source control. Release coordination remains procedural, and Git history is not yet the thing that actually determines the version.
Where Nerdbank.GitVersioning changes the model
This is the point at which Nerdbank.GitVersioning becomes valuable.
NBGV is not just a nicer string generator. Its real contribution is that it makes Git history the authoritative input for version stamping. That gives each commit a stable place in a release line without relying on pipeline run numbers, branch-name heuristics or tags that may be created later.
There are two moving parts:
- the
nbgvCLI used by the team - the
Nerdbank.GitVersioningbuild integration used by MSBuild
In most repositories the cleanest start is still:
1dotnet tool install --global nbgv
2nbgv install
nbgv install creates the initial version.json file and wires the build integration into the repository. Manual package management is still possible, but most teams benefit from letting the tool establish the baseline first.
What matters operationally is the division of responsibilities: the CLI is the workflow surface, while the package is what actually stamps assemblies, packages and generated metadata.
version.json should describe release policy, not just a number
Once NBGV is in place, the real contract is no longer a scattered collection of attributes. It is version.json.
1{
2 "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
3 "version": "2.4",
4 "assemblyVersion": {
5 "precision": "major"
6 },
7 "publicReleaseRefSpec": [
8 "^refs/heads/main$",
9 "^refs/tags/v\\d+\\.\\d+(\\.\\d+)?$"
10 ],
11 "release": {
12 "branchName": "release/v{version}",
13 "tagName": "v{version}",
14 "versionIncrement": "minor",
15 "firstUnstableTag": "alpha"
16 }
17}
This file carries more than a version prefix.
version defines the release line. assemblyVersion.precision defines how conservative the CLR-facing identity should be. publicReleaseRefSpec describes which canonical refs, such as refs/heads/main, should default to public-release formatting on build servers. The release block governs how nbgv prepare-release and nbgv tag behave.
That is release policy in source control, which is a much healthier place for it than a wiki page or a checklist somebody remembers only on release day.
Auto increment is still useful, but it needs the right signal
Most teams still want auto increment, and they should. The real question is what the increment means.
Incrementing on every build execution is the weakest model because it ties version identity to CI activity. Incrementing from repository history is stronger because it ties version identity to source state.
That is effectively what NBGV gives a repository. The human-defined release line stays in version.json, while Git history contributes version height and commit-derived traceability. The result is not just uniqueness. It is explainable uniqueness.
This is also the point where public and internal builds diverge. Internal builds often benefit from commit-rich version strings. Public artifacts usually need something cleaner. NBGV supports that split either via publicReleaseRefSpec or through the build property itself:
1dotnet pack -c Release -p:PublicRelease=true
The same property can be applied to dotnet build or dotnet publish because it is ultimately an MSBuild input.
Libraries and applications need slightly different policies
The tooling can be shared across repository types, but the policy should not be copy-pasted blindly.
Libraries usually benefit from:
- SemVer package versions that communicate API compatibility
- conservative
AssemblyVersionvalues across compatible release lines - package immutability after publish
- Source Link and symbols for traceability
Applications usually care more about:
- deployment-visible release identity
- runtime build metadata
- clear artifact naming
- build-once-promote-many flows
That difference matters because the same NBGV setup can support both, but it does not remove the need to define what counts as a breaking change and what qualifies as a public release.
Deterministic builds and idempotent artifacts are related, not synonymous
These two ideas are often blurred together, and that leads to sloppy release discussions.
A deterministic build means the same inputs produce the same output. An idempotent release process means the same source state does not lead to conflicting published artifacts or conflicting public versions.
Both matter. A repository can be strong in one area and weak in the other:
- deterministic builds with a release process that republishes versions incorrectly
- disciplined release naming with build output that drifts because SDKs or dependencies changed
The stronger target state is straightforward:
- one commit maps to one computed version identity
- builds run with pinned inputs
- the artifact is produced once
- later environments promote that artifact unchanged
That is why a small global.json file belongs in the same conversation:
1{
2 "sdk": {
3 "version": "10.0.100"
4 }
5}
Without a pinned SDK, versioning can still look correct while the actual bytes drift over time.
Monorepos need path awareness
In larger repositories, not every commit should influence every product’s version height. A docs-only change should not necessarily advance a backend package. A frontend-only change should not automatically affect a shared desktop tool.
This is where pathFilters earns its keep. It lets version-height calculation focus on relevant parts of the tree, which keeps version movement tied to meaningful change instead of general repository activity.
That capability is easy to overlook in small repositories and extremely useful in monorepos.
Runtime traceability should be designed in
Versioning is incomplete if the produced software cannot report its own identity reliably.
NBGV helps here by generating a ThisAssembly class during the build. The application can expose that data directly:
1namespace BenjaminAbt.Diagnostics;
2
3public static class BuildMetadata
4{
5 public static string ProductVersion
6 => ThisAssembly.AssemblyInformationalVersion;
7
8 public static string FileVersion
9 => ThisAssembly.AssemblyFileVersion;
10
11 public static string AssemblyVersion
12 => ThisAssembly.AssemblyVersion;
13}
This is preferable to carrying a hand-maintained version string in configuration or UI text. The artifact reports the values that were actually stamped into the build.
The failure mode is usually still manual drift
Even now, the most common .NET versioning problems are not exotic:
- version data is edited manually in several places
- public package versions include irrelevant pipeline counters
- CI emits release-looking artifacts from refs that were never meant to publish
- the same commit is rebuilt later under a different environment
- runtime diagnostics expose less information than the build already knows
Those are source-of-truth problems more than they are SemVer problems.
Conclusion
Modern .NET versioning is not mainly a question of whether the next release is called 2.4.0 or 2.4.1. The real engineering task is to keep source history, package identity, assembly metadata, CI output and runtime diagnostics aligned.
That is why NBGV is useful in real repositories. It gives the team an operator-facing workflow through nbgv, integrates directly into the build, and keeps version policy in source control instead of in scattered manual edits. Combined with pinned SDKs and build-once-promote-many release discipline, that turns versioning from a clerical task into reliable software identity.
Related articles

Dec 05, 2025 · 5 min read
IMemoryCache Entry Invalidation (Manual Cache Busting)
IMemoryCache is great for speeding up expensive operations (database reads, HTTP calls, heavy computations). But many real systems need more …

Nov 08, 2025 · 8 min read
.NET 10 Release: What's New (LTS) and What to Upgrade First
.NET 10 is the next Long-Term Support (LTS) release in the .NET family. LTS matters because it’s the version many teams standardize on …

Jun 24, 2026 · 14 min read
Local Aspire Development with Azure Cosmos DB and the Preview Emulator
Distributed applications tend to feel straightforward until a real cloud dependency enters the picture. Azure Cosmos DB is a good example. …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

Comments