32 min readMehdi Hadeli

Comparing .NET Versioning Tools for a Microsoft-Style SemVer Release Strategy

Introduction

The previous article defines a GitHub Flow strategy with continuous previews and tag-controlled RC and stable releases. Its sample implements the policy with Nerdbank.GitVersioning (NBGV).

This continuation keeps that release contract and changes the implementation. The same .NET 10 Web API is versioned with GitVersion, MinVer, NBGV, Arcade, and semantic-release so their configuration, CI integration, and tradeoffs can be compared directly. The first article explains why ordinary merges create previews, why RC and stable releases need reviewed version intent, and how Docker, runtime metadata, and Release Drafter carry the selected version. This article keeps that operating model and asks a narrower question: what changes when the version calculator changes?

All code is available in the Microsoft-style .NET versioning samples repository. The examples retain protected main, squash-merged pull requests, explicit release preparation, and tags on approved commits. They focus on version calculation and publishing the Web API, rather than repeating the container and /version implementation from the first article. Production deployment and environment approvals still need to be connected to your deployment platform.

This versioning strategy treats a version as a release contract, not only as an assembly number. It answers four questions for every artifact: which source commit produced it, whether it is safe for a particular audience, what compatibility promise it makes, and whether it has been approved for release. The same contract works for a deployed .NET application and for a NuGet library, although applications usually map prerelease stages to environments while libraries expose them directly to package consumers.

The lifecycle has three audiences. Development and early adopters receive continuous preview artifacts from accepted changes on main. A wider validation group receives a release candidate in staging after release intent has been reviewed. Production consumers receive a stable artifact only after the approved commit is tagged. This keeps development frequent while making the promotion boundary explicit: a tag approves one exact commit, and CI builds that selected source rather than rebuilding an unreviewed workstation state.

The strategy uses two related version forms. The short SemVer identity is easy to review and is used for release intent and tags, such as 1.0.0-preview.2, 1.0.0-rc.1, and 1.0.0. Published preview and RC artifacts can add the UTC date and CI run revision used by Microsoft-style package versions, such as 1.0.0-rc.1.YYDDD.RUN_NUMBER. Stable artifacts remain the clean version 1.0.0. The suffix makes CI builds unique and traceable without changing the meaning of the prerelease stage.

That contract maps to several .NET version fields, each with a different job:

FieldConsumerPolicy in this article
PackageVersionNuGet and package consumersPublic SemVer, including preview and rc identifiers.
AssemblyVersionCLR binding for strong-named assembliesA compatibility-oriented value, normally kept less volatile than CI build identity.
FileVersionWindows file properties and operational inspectionA numeric Major.Minor.Build.Revision value that can carry a CI revision.
InformationalVersionDiagnostics and support toolingThe full version plus commit or repository metadata when useful.

Keeping these fields separate prevents a CI run number from changing the public compatibility promise. Microsoft’s .NET library guidance recommends SemVer for the package version, prerelease suffixes for nonstable packages, and a CI build number in the file-version revision. The exact MSBuild properties vary by tool, but the ownership boundary should remain the same: release policy decides meaning, while build metadata helps identify the produced file.

SemVer precedence also gives the environments a predictable promotion path. 1.0.0-preview.2 is older than 1.0.0-rc.1, and every prerelease is lower precedence than 1.0.0. Numeric identifiers compare numerically, so rc.10 sorts after rc.2; avoid zero-padding or embedding an opaque value where consumers need to compare release maturity. A date and CI run identifier can improve traceability, but they must not replace the preview or rc stage and its ordinal.

The five tools are therefore versioning adapters around one policy, not interchangeable sources of truth. GitVersion interprets branch and Git-graph rules, MinVer starts from the nearest matching tag and adds Git height, NBGV combines committed version.json intent with Git height, Arcade supplies the .NET build infrastructure and MSBuild stamping conventions, and semantic-release derives release intent from Conventional Commits. The workflows normalize their outputs at the CI boundary: validate one release identity, format the artifact version once, then pass that exact value to dotnet publish, Docker, runtime metadata, and release tooling. There is also an important formatting difference: the NBGV sample retains the first article's CI artifact format, X.Y.Z-preview.N.YYDDD.RUN_NUMBER and X.Y.Z-rc.N.YYDDD.RUN_NUMBER, while the other samples currently publish shorter versions. Sharing the release policy does not mean that every sample already produces identical artifact names or implements the policy entirely inside its native tool.

The Microsoft-style SemVer strategy

Before comparing tools, establish the policy they must implement. The version calculator is replaceable; the release contract is not. This article uses the same strategy as part 1: GitHub Flow, SemVer, continuous previews, reviewed release intent, and tag-gated RC and stable publication.

SemVer identifies release maturity

The stable version follows MAJOR.MINOR.PATCH. A breaking API or behavior change increments MAJOR, a backward-compatible feature increments MINOR, and a backward-compatible fix increments PATCH. Before stable publication, the prerelease identifier communicates maturity:

1.0.0-preview.1
1.0.0-preview.2
1.0.0-rc.1
1.0.0-rc.2
1.0.0

preview means active development and early-adopter feedback. rc means feature-complete validation with only release-blocking fixes expected. The version without a prerelease label is stable. The v prefix belongs to the Git tag (v1.0.0-rc.1), not to the SemVer value passed to the .NET SDK or package metadata.

Microsoft-style CI artifact versions

The release identity and the published CI artifact have different jobs. The short SemVer value makes release intent easy to review and tag. Preview and RC artifacts may add a UTC date and CI run revision so every build is unique and traceable, following the style used by Microsoft packages:

Release identity: 1.0.0-preview.2
Published artifact: 1.0.0-preview.2.YYDDD.RUN_NUMBER
 
Release identity: 1.0.0-rc.1
Published artifact: 1.0.0-rc.1.YYDDD.RUN_NUMBER
 
Stable artifact: 1.0.0

The date and run number are additional prerelease identifiers for the CI artifact, not SemVer build metadata in the strict sense and not a replacement for the stage or ordinal. Stable artifacts stay clean so 1.0.0 remains the public release version. When a project does not need this Microsoft-style suffix, it can publish the short prerelease value instead; the release policy remains the same.

Release flow and control points

The policy has two kinds of changes:

  • Ordinary feature and fix merges do not edit version intent. Each accepted merge to protected main advances the preview sequence.
  • Release-preparation changes explicitly select the RC or stable phase. They are reviewed and merged before an operator creates the matching tag.

CI then calculates one version, validates the ref, and passes that value to dotnet publish, Docker, runtime metadata, and Release Drafter. A tag approves the exact commit; it does not silently rewrite a version calculated from a different commit. The version calculator may use Git history, committed files, MSBuild properties, or Conventional Commits, but it must honor the same boundaries.

The release contract

Use GitHub Flow. main is the only long-lived branch. Feature branches merge through pull requests, and a release is created by tagging the commit on main.

The example release has two automatically calculated previews followed by three tagged releases:

1.0.0-preview.1
1.0.0-preview.2
v1.0.0-rc.1
v1.0.0-rc.2
v1.0.0

The five versions represent one release line:

VersionIntended audienceAllowed change
1.0.0-preview.1Maintainers and early adoptersFeatures, API changes, fixes
1.0.0-preview.2Early adoptersFeatures, API changes, fixes
1.0.0-rc.1Wider validation groupRelease blockers and documentation
1.0.0-rc.2Final validation groupFixes required for stable release
1.0.0All consumersStable baseline

How each tool represents the five versions

The release events are the same for every sample. Preview calculation differs, while RC and stable publication is tag-gated. Each sample records release intent before tagging: NBGV uses version.json, GitVersion uses its configuration and release.env, MinVer uses version.env, Arcade uses MSBuild properties, and semantic-release uses release.env.

ToolPreview 1 and preview 2 after PR mergesRC1: v1.0.0-rc.1RC2: v1.0.0-rc.2Stable: v1.0.0
GitVersionCalculates preview versions from the Git graph and main rules.Uses the tag value directly.Uses the tag value directly.Uses the tag value directly.
MinVerDerives unique preview versions from the nearest tag and Git height.Reads the tag exactly.Reads the tag exactly.Reads the tag exactly.
NBGVUses committed version intent and Git height for exact 1.0.0-preview.N versions.Validates a matching tag, then publishes 1.0.0-rc.1.Validates a matching tag, then publishes 1.0.0-rc.2.Validates a matching tag, then publishes 1.0.0.
ArcadeUses 1.0.0-preview.${GITHUB_RUN_NUMBER}.Passes the tag value and sets OfficialBuild=true.Passes the tag value and sets OfficialBuild=true.Passes the tag value and sets OfficialBuild=true.
semantic-releaseUses Conventional Commits for the base and appends preview.${GITHUB_RUN_NUMBER}.Workflow bypasses calculation and uses the tag value.Workflow bypasses calculation and uses the tag value.Workflow bypasses calculation and uses the tag value.

All five samples use the previous article's promotion model as their reference. Their calculations and some validation-only rules differ, as the sections below explain. GitVersion and MinVer derive versions from Git history, NBGV combines committed intent with history, Arcade stamps adapter-supplied metadata, and semantic-release derives a stable base from Conventional Commits.

Shared implementation constraints

Each workflow fetches complete Git history, calculates the version once, and passes that exact value to dotnet publish with -p:Version. The versioning tools need tags and commit history, so every checkout uses fetch-depth: 0.

The three jobs keep the same boundaries as the earlier sample: build-test validates source, calculate-version produces one SemVer output, and publish builds the artifact and invokes Release Drafter. The sections below focus on what changes inside calculate-version for each tool.

Base versions and published versions

Keep the version used to test the release sequence separate from the version attached to a CI artifact. A local scenario should be able to assert 1.0.0-preview.1 or 1.0.0-rc.2 without knowing the current date or supplying a workflow run number.

SampleLocal calculationCI artifact formatting
GitVersionNative preview calculation; the adapter applies committed RC/stable intent.Short preview, RC, or stable version.
MinVerNative tag and commit-height version, including 1.0.0-rc.1.1 between RC tags.The same short native version.
NBGVCommitted intent and Git height, with the non-public commit suffix removed by the adapter.Preview and tagged RC versions add YYDDD.RUN_NUMBER; stable stays clean.
ArcadeThe adapter derives local preview height and RC intent; MSBuild stamps its inputs.Preview intent uses the CI run number; RC and stable stay short.
semantic-releaseNative analysis selects the stable base; the adapter supplies local preview height and RC intent.Preview intent uses the CI run number; RC and stable stay short.

The initialization version also differs: native MinVer returns 1.0.0-preview, while the other scenario fixtures start at 1.0.0-preview.0. That difference is documented rather than hidden behind an assertion that the native CLI cannot satisfy.

If your application needs the same Microsoft-style artifact format across all tools, add a consistent formatting step after base-version calculation. Preserve the stage ordinal, append the date and workflow revision only for published previews and RCs, and leave stable versions unsuffixed. That is an extension to these alternative samples, not their current behavior. The first article remains the reference for carrying the selected artifact version into container and runtime metadata.

The same release-helper interface

The first article uses a helper to make reviewed version changes explicit. Each alternative sample exposes the same commands: prepare-rc 1.0.0, prepare-stable 1.0.0, prepare-train 1.1.0, and tag. Run them from the selected sample directory. Preparation edits files; it does not commit, merge, or push. Tagging creates a local tag that you push separately after validation.

Sample helperWhat preparation changesHow tag selects a release
GitVersion helpernext-version in GitVersion configuration and the version/phase in release intent.Next RC tag ordinal, or the committed stable core.
MinVer helperMinimum major/minor, target release version, and phase in version intent.Next RC tag ordinal, or the committed stable core.
NBGV helperPreview/RC height template or stable version; removes the bootstrap offset for a new train or stable.Calls nbgv tag using the calculated version.
Arcade helperVersion prefix and release phase in MSBuild properties.Next RC tag ordinal, or the committed stable core.
semantic-release helperTarget release version and phase in release intent.Next RC tag ordinal, or the committed stable core.

For example, GitVersion preparation updates both its native base-version setting and the adapter's phase:

Source: gitversion-versioning-sample/release-version.sh

gitversion-versioning-sample/release-version.sh
prepare() {
  set_next_version "$2"
  set_value "$intent" GITVERSION_RELEASE_VERSION "$2"
  set_value "$intent" GITVERSION_RELEASE_PHASE "$1"
}

Those lines record the target version and phase in the same pull request. Native GitVersion supplies preview calculation, while the adapter needs the phase to distinguish an RC fix from ordinary preview development. Review both changes before merging.

The protected-branch flow remains the one established in the first article:

Release preparation example
git switch main
git pull --ff-only
git switch -c chore/prepare-1.0.0-rc
./release-version.sh prepare-rc 1.0.0
git add -A
git commit -m "chore: prepare 1.0.0 RC train"
git push -u origin chore/prepare-1.0.0-rc
# Open and squash-merge the PR; wait for validation.
git switch main
git pull --ff-only
./release-version.sh tag
git push origin v1.0.0-rc.1
 
# Merge an RC fix through a PR, update main, and validate it.
./release-version.sh tag
git push origin v1.0.0-rc.2
 
# Prepare stable on a new branch; review and squash-merge before tagging.
git switch -c chore/prepare-1.0.0
./release-version.sh prepare-stable 1.0.0

The preparation commands belong on short-lived branches; tag belongs on the approved merged commit. The RC fix needs no second prepare-rc call. Stable requires a separate intent change, so do not skip preparation and tag the RC2 commit as stable. After stable publication, prepare 1.1.0 on another branch to start the next feature train.

The counters are not interchangeable. NBGV advances the RC height for each accepted fix even without an intervening tag. The GitVersion, Arcade, and semantic-release adapters select the next ordinal from reachable RC tags, and their helpers select the next existing RC tag ordinal. Until RC2 is tagged, multiple fixes can therefore share the prospective rc.2 version. MinVer keeps its native intermediate version, such as rc.1.1, until an explicit RC2 tag exists. The tests verify the one-fix-between-tags scenario; they do not prove that all tools have NBGV's per-commit RC numbering.

GitVersion

GitVersion derives a version by interpreting the Git graph. Its official GitHubFlow/v1 template uses Continuous Delivery, patch increments on main, labels for other branch classes, tagged-commit discovery, and optional commit-message increments. This makes it the strongest choice here when branch topology and merge history are part of the version policy.

GitVersion setup

  1. Add GitVersion.yml at the repository root.
  2. Select GitHubFlow/v1 and label untagged main builds as preview.
  3. Fetch complete history and tags in CI.
  4. Install the pinned GitVersion.Tool CLI and expose SemVer as a job output.
  5. Pass the output into dotnet publish with -p:Version.

Official guides: configuration reference, GitHub Flow examples, and version increments.

The important settings map to this strategy as follows:

SettingWhy this sample sets it
workflow: GitHubFlow/v1Uses main plus short-lived branches without requiring a permanent release branch.
mode: ContinuousDeliveryGives every untagged accepted commit a prerelease version that CI can build safely.
next-version: 1.0.0Establishes the first development line before any stable version tag exists.
main.label: previewProduces the -preview.N suffix on untagged main commits.
main.increment: PatchControls GitVersion's default increment; the shared scenario overrides the next train explicitly with next-version: 1.1.0.
tag-prefix: "[vV]?"Lets GitVersion parse both 1.0.0 and repository tags prefixed with v.
commit-message-incrementing: EnabledAllows explicit +semver: directives to alter the next core version; disable it if only configuration may do that.

Source: gitversion-versioning-sample/GitVersion.yml

gitversion-versioning-sample/GitVersion.yml
workflow: GitHubFlow/v1
mode: ContinuousDelivery
next-version: 1.0.0
tag-prefix: '[vV]?'
semantic-version-format: Strict
assembly-versioning-scheme: MajorMinorPatch
assembly-file-versioning-scheme: MajorMinorPatch
commit-message-incrementing: Enabled
branches:
  main:
    label: preview
    increment: Patch
    is-main-branch: true

The highlighted settings tell GitVersion to use GitHub Flow, accept tags such as v1.0.0-rc.1, and emit preview versions for untagged commits on main. The workflow reads explicit release versions from GITHUB_REF_NAME because GitVersion's continuous-delivery calculation can otherwise keep its preview label when an rc tag points at a later commit.

Source: .github/workflows/gitversion.yml

.github/workflows/gitversion.yml
- name: Install GitVersion
  run: dotnet tool install --tool-path "$RUNNER_TEMP/gitversion" GitVersion.Tool --version 6.8.2
- name: Calculate version
  id: version
  shell: bash
  env:
    GITVERSION_CLI: ${{ runner.temp }}/gitversion/dotnet-gitversion
  run: |
    ./scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"

The CLI owns preview calculation. The wrapper honors committed RC or stable intent on untagged preparation commits and treats an explicit release tag as the release identity. During RC intent, it selects the next ordinal from reachable RC tags, so a fix after RC1 validates as rc.2, not preview.4. This is adapter policy, not a claim that native GitVersion calculates NBGV's RC sequence. The .NET SDK receives the resulting value through -p:Version.

GitVersion's preview number is based on Git height, not only on the number of merged pull requests. If next-version: 1.0.0 is used without a version baseline tag, the initialization commit contributes to that height: the first merged pull request can therefore produce 1.0.0-preview.2, followed by 1.0.0-preview.3. To make the first merged pull request produce 1.0.0-preview.1, establish the initialization commit as v1.0.0-preview.0. The scenario test performs this exact setup with repository.Tag("v1.0.0-preview.0") immediately after its initialization commit and before either feature merge.

Create this baseline tag once, immediately after the initialization commit and before merging feature pull requests:

git tag -a v1.0.0-preview.0 -m "1.0.0 preview baseline"
git push origin v1.0.0-preview.0

This tag establishes GitVersion's starting point; it is not a published preview release. The first feature merge can then produce 1.0.0-preview.1, followed by 1.0.0-preview.2.

The baseline is specific to the GitVersion sample. Subsequent previews remain untagged; only the one-time baseline, RCs, and stable release have Git tags.

How GitVersion handles the five phases

  • Untagged main commits with preview intent receive calculated preview versions from Git history.
  • RC intent keeps preparation and fixes in the RC phase; stable intent produces the stable core for validation before tagging.
  • The workflow also preserves v1.0.0-rc.1 and v1.0.0-rc.2 instead of asking GitVersion to reinterpret those tags.
  • v1.0.0 removes the prerelease label and becomes stable.
  • Full history is mandatory because GitVersion evaluates version sources and the commit graph.

GitVersion is a good fit when branch context matters. It is also the most configuration-heavy option in this set. A team should agree on its branch model before using its calculated versions as public package versions.

Sample files: GitVersion.csproj, GitVersion.yml, and scenario tests.

MinVer

MinVer follows a smaller rule: release versions come from Git tags. Its documentation calls this “tag first.” On an exactly tagged commit, the tag is the version. On an untagged commit, MinVer derives an interim prerelease from the nearest version tag and can append Git height.

MinVer setup

  1. Install the pinned minver-cli tool in CI.
  2. Pass --tag-prefix v, --minimum-major-minor 1.0, and --default-pre-release-identifiers preview.
  3. Keep Git height enabled so successive untagged commits are unique; at initial height zero MinVer emits 1.0.0-preview, then appends .1, .2, and later heights.
  4. Calculate once, then pass the result to dotnet publish with -p:Version.

Official guides: MinVer options and MinVer CLI reference.

The CLI exposes the controls needed by this strategy:

CLI optionPurpose in this strategy
--tag-prefix v / -t vRecognizes RC and stable tags such as v1.0.0-rc.1 and v1.0.0.
--minimum-major-minor 1.0Keeps builds before the first release on the intended 1.0 line instead of 0.0.
--default-pre-release-identifiers previewNames untagged builds preview rather than MinVer's default prerelease identifier.
Omit --ignore-heightMakes successive merges unique and ordered.

These CLI options make the tag vocabulary match the release contract. Git height provides both uniqueness across merged commits and the numbered preview suffix used after a stable tag.

Source: .github/workflows/minver.yml

.github/workflows/minver.yml
- name: Install MinVer CLI
  run: dotnet tool install --tool-path "$RUNNER_TEMP/minver" minver-cli --version 8.0.0
- name: Calculate version
  id: version
  shell: bash
  env:
    MINVER_CLI: ${{ runner.temp }}/minver/minver
  run: |
    ./scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"

How MinVer handles the five phases

  • Each explicit tag is used exactly on its tagged commit.
  • Before the first tag, untagged merges produce preview versions with increasing Git height. After v1.0.0-rc.1, MinVer produces an internal build such as 1.0.0-rc.1.1 until the next explicit tag.
  • The stable tag v1.0.0 produces 1.0.0.
  • A later untagged commit defaults to the next patch prerelease line.
  • Shallow history can hide the nearest tag, so use fetch-depth: 0.

MinVer does not need a release branch. That matches GitHub Flow well, because the tag, rather than a branch name, is the release decision. Its smaller rule set is an advantage when a team wants predictable tag behavior and little automation magic.

Sample files: MinVer.csproj, MinVer README, and scenario tests.

Nerdbank.GitVersioning

Nerdbank.GitVersioning, usually called NBGV, starts with a committed version.json. Unlike MinVer, it does not require tags to calculate a unique build identity. It combines an author-controlled base version with Git height and commit information, which makes local and CI builds reproducible from the same commit.

NBGV setup

  1. Commit version.json with the initial development line, here 1.0.0-preview.{height}.
  2. Declare release refs through publicReleaseRefSpec.
  3. Install the pinned nbgv CLI in CI.
  4. Run the shared calculate-version.sh policy adapter for every main or release-tag build.
  5. Let the adapter validate that a release tag matches NBGV's calculated base version.
  6. Pass the adapter's selected version and metadata to dotnet publish with -p:Version and -p:InformationalVersion.

Official guides: version.json reference, versioning workflow, and cloud-build integration.

NBGV deliberately keeps more policy in source control than MinVer:

SettingPurpose in this strategy
version: 1.0.0-preview.{height}Declares the initial development line and exposes Git height.
versionHeightOffset: -1Reserves preview.0 for validation of the initialization commit.
versionHeightOffsetAppliesToLimits the bootstrap offset to the initial preview train.
publicReleaseRefSpecIdentifies supported vMAJOR.MINOR.PATCH and -rc.N release refs.
calculate-version.shConverts NBGV output into the shared CI version and rejects mismatched release tags.

After stable, the next preview train is an explicit version-intent change. A release-preparation branch runs release-version.sh prepare-train 1.1.0, which changes version.json to 1.1.0-preview.{height} and removes the initial bootstrap offset. This keeps the release decision in reviewed source control instead of silently inferring a new patch line from the nearest stable tag.

Source: nerdbank-versioning-sample/version.json

nerdbank-versioning-sample/version.json
{
  "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/Nerdbank.GitVersioning.Schema/version.schema.json",
  "version": "1.0.0-preview.{height}",
  "versionHeightOffset": -1,
  "versionHeightOffsetAppliesTo": "1.0.0-preview.{height}",
  "publicReleaseRefSpec": ["^refs/tags/v\\d+\\.\\d+\\.\\d+(?:-rc\\.\\d+)?$"]
}

The committed base makes the CI calculation deterministic. NBGV's official workflow also supports nbgv set-version and nbgv tag; this sample uses the local release-version.sh helper for those release-intent changes. The sample does not add the NBGV MSBuild package because CI supplies the final version to the .NET SDK.

Source: .github/workflows/nerdbank.yml

.github/workflows/nerdbank.yml
nbgv="$RUNNER_TEMP/nbgv/nbgv"
dotnet tool install --tool-path "$RUNNER_TEMP/nbgv" nbgv --version 3.10.94
bash scripts/calculate-version.sh . "$nbgv" github-output | tee -a "$GITHUB_OUTPUT"

The script has two modes. With the default mode it prints one base version for local validation and tests. With github-output, it emits version, assembly_version, informational_version, environment, and commit lines for the workflow. It calculates NBGV's SemVer2 value, removes only the non-public .g<commit> suffix, and checks any release tag at HEAD against that base version. A mismatched tag fails the job rather than silently changing the artifact version.

For an untagged main build, preview.0 selects validation only. Later previews receive the NBGV preview ordinal plus the UTC YYDDD.RUN_NUMBER suffix and target dev. A matching RC tag receives the same date and run suffix and targets staging; a matching stable tag remains the clean NBGV version and targets production.

How NBGV handles the five phases

  • The initialization commit calculates 1.0.0-preview.0 and is validation-only.
  • Ordinary feature merges calculate 1.0.0-preview.1, 1.0.0-preview.2, and so on from Git height.
  • A release-intent merge changes version.json to 1.0.0-rc.{height}; the first RC merge calculates 1.0.0-rc.1.
  • Matching RC and stable tags approve already-calculated versions; they do not override an unrelated NBGV result.
  • Stable promotion changes version intent to 1.0.0; the next preview train requires another explicit base-version change.

NBGV is useful when every commit needs a reproducible identity and release intent belongs in source control. It is different from MinVer: MinVer is deliberately tag-first, while NBGV can calculate versions without waiting for a tag.

Sample files: version.json, NBGV project, and scenario tests.

Arcade

Arcade is shared .NET engineering infrastructure rather than a small standalone tag calculator. It provides common build targets, official-build concepts, dependency flow, and conventions used by many dotnet repositories. The Arcade SDK defaults to SemVer 2 output, but it does not decide our five-stage policy by itself.

Arcade setup

  1. Add Microsoft.DotNet.Arcade.Sdk.
  2. Configure the Microsoft dotnet-eng NuGet feed.
  3. Put shared version properties in Directory.Build.props.
  4. Give local builds a valid fallback such as 1.0.0-preview.0.
  5. Calculate 1.0.0-preview.${GITHUB_RUN_NUMBER} on untagged main builds with preview intent; keep RC and stable intent in their selected phase.
  6. Use explicit tags only for RC and stable events.
  7. Set OfficialBuild=true only for selected tagged builds.

Official guides: Arcade SDK, especially its eng/Versions.props, repository layout, and official-build sections.

Arcade provides the build infrastructure, while this sample supplies the release policy:

Property or inputPurpose in this strategy
VersionPrefixHolds the current stable core, initially 1.0.0; prepare-train 1.1.0 advances the shared feature train.
GITHUB_RUN_NUMBERSupplies a monotonically increasing preview suffix for accepted main builds.
VersionCarries the complete SemVer passed to the SDK and application build.
PackageVersionKeeps package identity synchronized with Version.
OfficialBuildDistinguishes tagged RC/stable artifacts from ordinary local or preview builds.

Full Arcade repositories usually keep shared versions in eng/Versions.props and invoke common eng build scripts. This compact sample uses Directory.Build.props so the versioning behavior remains visible without reproducing the entire Arcade repository layout.

Source: arcade-versioning-sample/Directory.Build.props

arcade-versioning-sample/Directory.Build.props
<PropertyGroup>
  <VersionPrefix Condition="'$(VersionPrefix)' == ''">1.0.0</VersionPrefix>
  <ReleasePhase Condition="'$(ReleasePhase)' == ''">preview</ReleasePhase>
  <ReleaseCandidateNumber Condition="'$(ReleaseCandidateNumber)' == ''">1</ReleaseCandidateNumber>
  <Version Condition="'$(Version)' == '' and '$(ReleasePhase)' == 'rc'">$(VersionPrefix)-rc.$(ReleaseCandidateNumber)</Version>
  <Version Condition="'$(Version)' == '' and '$(ReleasePhase)' == 'stable'">$(VersionPrefix)</Version>
  <Version Condition="'$(Version)' == '' and '$(GITHUB_RUN_NUMBER)' != ''">$(VersionPrefix)-preview.$(GITHUB_RUN_NUMBER)</Version>
  <Version Condition="'$(Version)' == ''">$(VersionPrefix)-preview.0</Version>
  <PackageVersion Condition="'$(PackageVersion)' == ''">$(Version)</PackageVersion>
  <OfficialBuild Condition="'$(OfficialBuild)' == '' and '$(GITHUB_REF_TYPE)' == 'tag'">true</OfficialBuild>
</PropertyGroup>

The phase-specific properties prevent RC and stable preparation builds from falling back to preview. The shell adapter derives local preview ordinals from Git history when no CI run number is supplied, and calculates the next RC ordinal from reachable tags. Arcade stamps the supplied metadata; it is not the Git-history calculator.

Source: .github/workflows/arcade.yml

.github/workflows/arcade.yml
- name: Calculate version
  id: version
  shell: bash
  run: |
    ./scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"

How Arcade handles the five phases

  • Arcade stamps the version supplied through MSBuild properties.
  • The workflow, not Arcade, maps untagged builds to numbered previews.
  • Untagged preview-intent builds provide preview values; RC preparation and fixes remain in the RC phase until stable intent is committed. Tags authorize publication.
  • OfficialBuild separates selected release artifacts from local builds.
  • Stable publication removes the prerelease suffix by supplying 1.0.0.

Arcade is the right choice when a repository wants to follow .NET engineering infrastructure and can accept the additional feed and build-system setup. It is not the best first choice when the only need is a version from a tag.

Sample files: Arcade project, Directory.Build.props, and scenario tests.

semantic-release

semantic-release is a Node.js release orchestrator. It analyzes Conventional Commits, chooses a SemVer increment, generates notes, creates tags, and can publish through plugins. It is not a .NET MSBuild version provider.

The sample places a v0.0.0 bootstrap tag on the initialization commit. Its shell calculator recognizes this as a history anchor rather than a real release and emits the committed first-train intent, 1.0.0-preview.0.

Its default release mapping is consumer-impact based: fix produces a patch, feat produces a minor, and BREAKING CHANGE produces a major. Official semantic-release prerelease support normally uses dedicated prerelease branches such as beta. Our no-release-branch policy uses semantic-release to calculate the next stable base on main, then CI appends the preview sequence or accepts an explicit tag. That adaptation should be understood rather than hidden.

semantic-release setup

  1. Add semantic-release and the commit analyzer plugins to package.json.
  2. Configure branches: ['main'] and tagFormat: 'v${version}'.
  3. Enforce Conventional Commit pull-request titles or commit messages.
  4. Call the JavaScript API with dryRun: true to retrieve nextRelease.version.
  5. Append -preview.${GITHUB_RUN_NUMBER} for preview-intent main builds; let the adapter honor RC and stable intent separately.
  6. Use explicit SemVer tags only for RC and stable events.
  7. Pass the final value into dotnet publish.
  8. Keep Release Drafter as the human-reviewed GitHub release-note source.

Official guides: configuration, workflow configuration, and JavaScript API.

Source: semantic-release-versioning-sample/release.config.cjs

semantic-release-versioning-sample/release.config.cjs
module.exports = {
  branches: ['main'],
  tagFormat: 'v${version}',
  plugins: [
    '@semantic-release/commit-analyzer',
    '@semantic-release/release-notes-generator',
    [
      '@semantic-release/exec',
      {
        publishCmd:
          'dotnet publish src/semantic-release-versioning-sample.csproj --configuration Release --output artifacts/${nextRelease.version} -p:Version=${nextRelease.version}',
      },
    ],
    '@semantic-release/github',
  ],
}

The configuration has separate responsibilities:

Setting or pluginPurpose in this strategy
branches: ["main"]Restricts version analysis to the protected release branch.
tagFormat: "v${version}"Makes existing RC/stable tags discoverable in the same format used by the other samples.
@semantic-release/commit-analyzerMaps Conventional Commits to the next stable core version.
@semantic-release/release-notes-generatorCalculates notes during dry run; Release Drafter remains the reviewed GitHub release-note source.
@semantic-release/execDemonstrates how semantic-release can pass ${nextRelease.version} into dotnet publish.
@semantic-release/githubSupplies GitHub integration when semantic-release owns publication; our workflow keeps final control.

Source: semantic-release-versioning-sample/scripts/calculate-version.mjs

semantic-release-versioning-sample/scripts/calculate-version.mjs
import semanticRelease from 'semantic-release'
 
const result = await semanticRelease(
  {
    ci: false,
    dryRun: true,
  },
  {
    cwd: process.cwd(),
    env: process.env,
    stderr: process.stderr,
    stdout: process.stderr,
  }
)
 
if (result?.nextRelease?.version) {
  process.stdout.write(result.nextRelease.version)
}

Source: .github/workflows/semantic-release.yml

.github/workflows/semantic-release.yml
- name: Calculate version
  id: version
  shell: bash
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    ./scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"

How semantic-release handles the five phases

  • Conventional Commits determine the next stable base, such as 1.0.0.
  • Preview intent creates numbered preview builds; RC intent keeps preparation and fixes in the RC phase before tagging.
  • Preview numbers come from CI runs; explicit rc.1 and rc.2 tags are preserved.
  • The stable tag supplies 1.0.0 directly to the .NET build.
  • @semantic-release/exec can run dotnet publish, but the workflow shown here keeps build/publish steps visible in GitHub Actions.

This division avoids a false promise that semantic-release understands .csproj metadata. The sample uses semantic-release for orchestration and the .NET CLI for the actual application build.

Local scenario tests supply no run numbers: the shell adapter derives preview ordinals from Git history since the release-intent change. CI can still supply GITHUB_RUN_NUMBER for preview artifacts. RC fixes use the next reachable RC-tag ordinal, so the local sequence remains rc.1, rc.2, stable, then the new preview train.

Sample files: package.json and release.config.cjs.

Release Drafter keeps the same responsibility

Release Drafter keeps the same responsibility described in the previous article: previews remain drafts, RC tags publish prereleases, and stable tags publish the latest release. Each workflow supplies its calculated version to the same action inputs, so Release Drafter never recalculates the artifact version.

The workflows share release-drafter.yml and the same action inputs, but their publish-job conditions are not identical. The GitVersion workflow skips untagged RC/stable intent because those commits are validation-only. The MinVer workflow accepts any untagged prerelease for draft artifacts, including an intermediate 1.0.0-rc.1.1. That draft is not a public staging release: public RC/stable publication still requires a tag.

If you require the first article's exact rule that every untagged RC-preparation or RC-fix commit is validation-only, narrow MinVer's publish condition before adopting it. Likewise, do not assume every sample excludes its initial preview from draft publication just because NBGV excludes preview.0. The first article's deployment approvals and container publication must be implemented separately; a release name or an environment output is not a deployment.

Moving from NBGV to another tool

Keep the release operator's workflow stable while replacing the version provider:

  1. Commit the chosen tool's version intent and configure the initial baseline. GitVersion's preview baseline and semantic-release's bootstrap anchor are sample-specific; they are not public releases.
  2. Run both the helper-driven and direct-input scenarios. Inspect calculated versions and tags separately, including the RC fix and the first three previews of the next train.
  3. Preserve the CI output contract: version, assembly_version, informational_version, environment, and commit. Decide explicitly whether artifact formatting stays short or retains the first article's date/revision suffix.
  4. Review publication conditions. Confirm which main-branch commits create drafts, which are validation-only, and which tags publish RC or stable artifacts. Reject unsupported and mismatched tags before publishing.
  5. Carry the selected metadata into your container and runtime implementation from the first article. Keep production approval and deployment wiring separate from calculation and GitHub release publication.

Switch at the start of a reviewed release train, and retain immutable tags and already-published artifact versions. Recalculating an old commit with a different tool can produce a different version; it does not justify replacing the release that consumers already received.

Which tool should you choose?

ToolPrimary source of truthBest fitMain tradeoff
GitVersionGit graph, branch rules, tagsBranch-aware repositoriesMost policy and configuration
MinVerVersion tagsSimple tag-driven libraries/appsUntagged build uniqueness needs care
NBGVversion.json plus commit identityReproducible version per commitRelease promotion differs from pure tag-first flow
ArcadeMSBuild/repository engineering propertiesRepositories adopting dotnet engineering infrastructureMuch broader than version calculation
semantic-releaseConventional Commits and release historyMulti-ecosystem automated releasesNode orchestration, not native MSBuild versioning

For a small .NET application using GitHub Flow, MinVer or NBGV is usually the simplest starting point. The correct choice depends on whether tags or every commit is the primary source of truth.

Validation checklist

Each sample has a repository fixture that creates a temporary Git repository and exercises the release process, rather than calculating expected versions from the current checkout. The scenario suite has three responsibilities:

  1. Release_helper_commands_follow_release_scenario prepares intent and creates tags through the helper.
  2. A direct-tool/input scenario prepares the same history without using the release helper, then verifies the tool or adapter appropriate to that sample.
  3. Mismatched_manual_tag_is_rejected verifies that an unrelated tag such as v9.9.9 cannot silently replace the intended release version.

The complete local sequence includes the next train, not only the stable release:

Local release scenario
initialize -> 1.0.0-preview.0
feature merge -> 1.0.0-preview.1
feature merge -> 1.0.0-preview.2
prepare RC intent and tag -> 1.0.0-rc.1
merge RC fix and tag -> 1.0.0-rc.2
prepare stable intent and tag -> 1.0.0
prepare next train -> 1.1.0-preview.1
feature merge -> 1.1.0-preview.2
feature merge -> 1.1.0-preview.3

MinVer's initialization and untagged RC-fix versions are the native exceptions described earlier. Scenario assertions supply no run numbers. They check the calculated version separately from the tag at HEAD, and compare native output where the native tool owns that calculation. For GitVersion's RC policy, Arcade's Git-history adapter, and semantic-release's prerelease formatting, the wrapper owns behavior that the native tool does not provide. A passing wrapper assertion is not evidence of native-tool equivalence.

Run the complete suite from the sample repository root:

dotnet test microsoft-style-dotnet-versioning-samples.slnx --configuration Release

These tests cover local version calculation, preparation commands, and tag selection. They do not exercise the dated CI artifact suffix, GitHub permissions, Release Drafter publication, artifact upload, or deployment approvals. Verify those separately in the real workflow before adopting the samples for production.

Tool references

Conclusion

The release policy does not require one specific tool. GitVersion and MinVer use Git history directly, NBGV combines committed intent with Git identity, Arcade relies on MSBuild and CI inputs, and semantic-release uses commit semantics. Keeping the contract fixed makes those differences visible and lets a team choose a tool without redesigning its release process.