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

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
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:
| Field | Consumer | Policy in this article |
|---|---|---|
PackageVersion | NuGet and package consumers | Public SemVer, including preview and rc identifiers. |
AssemblyVersion | CLR binding for strong-named assemblies | A compatibility-oriented value, normally kept less volatile than CI build identity. |
FileVersion | Windows file properties and operational inspection | A numeric Major.Minor.Build.Revision value that can carry a CI revision. |
InformationalVersion | Diagnostics and support tooling | The 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 its own MSBuild version targets, 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 GitVersion, MinVer, and NBGV samples produce the first article's CI artifact format, X.Y.Z-preview.N.YYDDD.RUN_NUMBER and X.Y.Z-rc.N.YYDDD.RUN_NUMBER. Arcade and semantic-release also stamp previews, but they leave a tagged release untouched: Arcade's stamp is its own SHORT_DATE (yy * 1000 + mm * 50 + dd) and semantic-release adds YYDDD.RUN_NUMBER, and in both samples the tag is passed through and published with no stamp at all, so 1.0.0-rc.1 stays 1.0.0-rc.1. 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.0preview 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.0The 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
mainadvances 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.0The five versions represent one release line:
| Version | Intended audience | Allowed change |
|---|---|---|
1.0.0-preview.1 | Maintainers and early adopters | Features, API changes, fixes |
1.0.0-preview.2 | Early adopters | Features, API changes, fixes |
1.0.0-rc.1 | Wider validation group | Release blockers and documentation |
1.0.0-rc.2 | Final validation group | Fixes required for stable release |
1.0.0 | All consumers | Stable 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. The source of release intent differs too: NBGV reads version.json and Arcade reads committed inputs in eng/Versions.props. GitVersion, MinVer, and semantic-release keep no separate intent file, and for semantic-release the Conventional Commit history is the intent. In GitVersion a release tag on main supplies the RC or stable identity, and MinVer is tag-first by design: its CLI options set only the tag prefix, the minimum major and minor line, and the default prerelease identifier.
| Tool | Preview 1 and preview 2 after PR merges | RC1: v1.0.0-rc.1 | RC2: v1.0.0-rc.2 | Stable: v1.0.0 |
|---|---|---|---|---|
| GitVersion | Calculates preview versions from the Git graph and main rules. | Reads the tag value directly. | Reads the tag value directly. | Reads the tag value directly. |
| MinVer | Derives unique preview versions from the nearest tag and Git height. | Reads the tag exactly. | Reads the tag exactly. | Reads the tag exactly. |
| NBGV | Uses 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. |
| Arcade | Stamps committed eng/Versions.props inputs and adds its own date stamp. | Reads the tag and publishes it exactly, unstamped. | Reads the tag and publishes it exactly, unstamped. | Reads the tag and publishes it exactly, unstamped. |
| semantic-release | Derives the base from Conventional Commits and counts commits since the last release tag for the preview ordinal. | Reads the tag value directly. | Reads the tag value directly. | Reads the tag value directly. |
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 assembles and stamps versions from committed inputs and a CI build id, 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.
| Sample | Local calculation | CI artifact formatting |
|---|---|---|
| GitVersion | Native preview calculation; the formatter reads the release tag at HEAD for RC/stable. | Preview and RC add YYDDD.RUN_NUMBER; stable stays clean. |
| MinVer | Native tag and commit-height version from minver-cli, including 1.0.0-rc.1.1 between RC tags. | Preview and RC add YYDDD.RUN_NUMBER; stable stays clean. |
| NBGV | Committed 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. |
| Arcade | Arcade's own version targets assemble the version from committed inputs and the CI build id; a tagged release bypasses the stamp. | Preview adds Arcade's SHORT_DATE.revision; RC and stable stay exact. |
| semantic-release | Native analysis selects the base; the adapter counts commits since the last release tag for the preview ordinal. | Preview adds YYDDD.RUN_NUMBER; RC and stable stay exact. |
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 tool cannot satisfy.
If your application needs the same Microsoft-style artifact format across all tools, keep the base-version calculation separate from a formatting step. GitVersion, MinVer, NBGV, and semantic-release append YYDDD.RUN_NUMBER inside their wrapper scripts, and Arcade appends its own SHORT_DATE.revision stamp, so none of them needs an extra step. The Microsoft-style format preserves the stage ordinal, appends the date and workflow revision only for published previews and RCs, and leaves stable versions unsuffixed. The first article remains the reference for carrying the selected artifact version into container and runtime metadata.
How each tool records release intent
The first article uses a helper to make reviewed version changes explicit, and only one of the five alternative samples keeps that helper. NBGV's release-version.sh exposes the same commands as the first article: 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. The other four samples record the same intent elsewhere, so they need no helper.
| Release intent | What the reviewed change is | How the release tag is created |
|---|---|---|
| GitVersion | Nothing; a release tag on the approved main commit is the intent. | Create the tag directly with git tag. |
| MinVer | Nothing per release; the -m option in scripts/calculate-version.sh changes only for a new line. | Create the tag directly with git tag. |
NBGV release-version.sh | Preview/RC height template or stable version; removes the bootstrap offset for a new train or stable. | Calls nbgv tag using the calculated version. |
| Arcade | A committed edit to VersionPrefix and PreReleaseVersionLabel in eng/Versions.props. | Create the tag directly with git tag. |
| semantic-release | Nothing; the commit analyzer derives the next base, and a feat: after a stable tag opens the next minor line on its own. | Create the tag directly with git tag. |
GitVersion and MinVer are the closest pair: a release tag on main supplies the RC or stable identity, and MinVer keeps only its minimum major and minor line in scripts/calculate-version.sh. Arcade is not that case: Arcade has no tag-first mode, so a tag still cannot stand alone, and the reviewed intent lives on as committed properties in eng/Versions.props. semantic-release differs again: the commit analyzer derives the base, so reviewed intent is the squash-commit title rather than a file. Only NBGV still uses release-version.sh, and it does so to record reviewed intent in a file.
Source: gitversion-versioning-sample/GitVersion.yml
branches:
main:
label: preview
increment: Patch
is-main-branch: trueGitVersion labels every untagged main commit as a preview; a release tag on main is the approval that turns that commit into rc or stable. Review the tag the same way the other samples review their preparation change.
The protected-branch flow remains the one established in the first article. The example below is NBGV's, because NBGV is the sample that still keeps a preparation helper; the other samples reach the same states by tagging directly.
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.0The 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. GitVersion and MinVer replace the helper's tag command with git tag, because both treat the tag on main as the release intent.
The counters are not interchangeable. NBGV advances the RC height for each accepted fix even without an intervening tag. The Arcade adapter instead selects the next ordinal from reachable RC tags, so until RC2 is tagged multiple fixes can share the prospective rc.2 version. MinVer keeps its native intermediate version, such as rc.1.1, until an explicit RC2 tag exists. semantic-release has no release-candidate channel on main at all, so an untagged fix after RC1 returns to the preview train and keeps counting commits. The GitVersion formatter reads only the tag at HEAD, so untagged fixes remain previews until the next RC tag; its preview counter, like semantic-release's, also counts commits rather than pull requests, so it matches the pull-request count only when pull requests are squash-merged. The tests verify the one-fix-between-tags scenario; they do not prove that every tool has 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
- Add
GitVersion.ymlat the repository root. - Select
GitHubFlow/v1and label untaggedmainbuilds aspreview. - Map Conventional Commit messages (
feat:,fix:,!) to version increments. - Allow only squash merges, and fail the version job when
HEADis a merge commit. Preview numbering counts commits, so a merge commit advances it by more than one per pull request. - Fetch complete history and tags in CI.
- Install the pinned
GitVersion.ToolCLI and run the CI formatter to expose the version as a job output. - Pass the output into
dotnet publishwith-p:Version.
Official guides: configuration reference, GitHub Flow examples, and version increments.
The important settings map to this strategy as follows:
| Setting | Why this sample sets it |
|---|---|
workflow: GitHubFlow/v1 | Uses main plus short-lived branches without requiring a permanent release branch. |
mode: ContinuousDelivery | Gives every untagged accepted commit a prerelease version that CI can build safely. |
next-version: 1.0.0 | Establishes the first development line before any stable version tag exists. |
main.label: preview | Produces the -preview.N suffix on untagged main commits. |
main.increment: Patch | Controls GitVersion's default increment; a feat: after a stable tag opens the next minor train. |
tag-prefix: "[vV]?" | Lets GitVersion parse both 1.0.0 and repository tags prefixed with v. |
*-bump-message | Maps Conventional Commits (feat:, fix:, !) to major, minor, and patch increments. |
Source: 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
major-version-bump-message: '^[a-z]+(\([^)]+\))?!:'
minor-version-bump-message: '^feat(\([^)]+\))?:'
patch-version-bump-message: '^(fix|perf|refactor)(\([^)]+\))?:'
no-bump-message: '^(build|chore|ci|docs|style|test)(\([^)]+\))?:'
branches:
main:
label: preview
increment: Patch
is-main-branch: true
feature:
label: '{BranchName}'
increment: InheritThe highlighted settings tell GitVersion to use GitHub Flow, accept tags such as v1.0.0-rc.1, emit preview versions for untagged commits on main, and drive the next-train increment from Conventional Commit messages. GitVersion never lets a prerelease tag's label win on a main branch, so the CI formatter reads a release tag at HEAD directly to obtain the RC or stable identity; stable tags are returned verbatim by GitVersion itself.
Source: gitversion-versioning-sample/.github/workflows/build-and-publish.yml
- name: Install GitVersion
run: dotnet tool install --tool-path "$RUNNER_TEMP/gitversion" GitVersion.Tool --version 6.8.2
- name: Enforce squash merges
shell: bash
run: bash .github/scripts/require-squash-merge.sh
- name: Calculate version
id: version
shell: bash
env:
GITVERSION_CLI: ${{ runner.temp }}/gitversion/dotnet-gitversion
run: |
bash scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"GitVersion owns preview calculation on main. In its github-output mode the script emits the calculated version, the assembly version, the informational version, the deployment environment, and the commit, so the calculate-version job can expose all five. Because GitVersion cannot emit an RC label from a prerelease tag on a main branch, the CI formatter reads the release tag at HEAD to obtain the RC or stable identity, then appends the YYDDD.RUN_NUMBER build suffix to prereleases and maps the build to an environment. Successive fixes before the next tag remain previews, so an RC fix is promoted by tagging the chosen main commit. The .NET SDK receives the resulting value through -p:Version.
That tag read is the part of the calculation GitVersion cannot do on its own:
Source: gitversion-versioning-sample/scripts/calculate-version.sh
release_tags="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$' || true)"
# A stable tag outranks a prerelease tag when both point at the same commit,
# which is how a validated release candidate is promoted to stable.
release_tag="$(printf '%s\n' "$release_tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)"
if [[ -z "$release_tag" ]]; then
release_tag="$(printf '%s\n' "$release_tags" | sort -V | tail -n 1 || true)"
fi
if [[ -n "$release_tag" ]]; then
version="${release_tag#v}"
else
version="$(MSYS_NO_PATHCONV=1 "$gitversion_cli" /showvariable SemVer)"
fi
if [[ "$output_mode" != "github-output" ]]; then
printf '%s\n' "$version"
exit 0
fiThe script prefers a release tag at HEAD and falls back to dotnet-gitversion /showvariable SemVer only when no release tag exists. That ordering is what lets a tag approve an existing commit without renumbering it: v1.0.0-rc.1 becomes 1.0.0-rc.1, and a stable tag on the same commit outranks the candidate. Ordinary merges on main carry no release tag, so they keep the preview value GitVersion calculated from the Git graph and GitVersion.yml.
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.
The baseline sets the starting point but not the step size. GitVersion's preview number is its CommitsSinceVersionSource value, so it counts commits rather than pull requests, and a merge commit contributes every commit in the branch plus itself:
| Merge style | Two-commit pull request | Change per pull request |
|---|---|---|
Merge commit (--no-ff) | .0 → .3 → .6 | +3 |
| Squash and merge | .0 → .1 → .2 | +1 |
| Rebase and merge (two commits land) | .0 → .2 → .4 | +2 |
A single two-commit pull request merged with a merge commit therefore takes 1.0.0-preview.12 to 1.0.0-preview.15 instead of 1.0.0-preview.13, which looks like a calculation bug but is the documented counter. No GitVersion mode, version strategy, or workflow changes it: mode: Mainline is not a valid mode in GitVersion 6, and adding the Mainline version strategy still advances by more than one per merge commit. Because the release model treats one accepted pull request as one preview step, this sample allows squash merges only and fails the version job when HEAD is a merge commit.
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.0This 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
maincommits receive calculatedpreviewversions from Git history. - A release tag on a
maincommit supplies the RC identity, such asv1.0.0-rc.1orv1.0.0-rc.2, and the formatter reads it directly. - Untagged commits between tags remain
previewversions from GitVersion. - Tagging
v1.0.0onmainsupplies the stable version1.0.0. - A
feat:merged after the stable tag opens the next minor train at1.1.0-preview.1. - One accepted pull request advances
preview.Nby exactly one only when it squash-merges; thecalculate-versionjob rejects a merge commit onmain. - 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-versioning-sample.csproj, GitVersion.yml, scripts/calculate-version.sh, .github/scripts/require-squash-merge.sh, 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
- Install the pinned
minver-clitool in CI. - Run
scripts/calculate-version.sh, which passes-t v,-m 1.0, and-p previewso the tag vocabulary matches the release contract. - 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. - Disable Allow rebase merging in the repository pull-request settings. Height follows the first parent, so squash merging and merge commits are both safe; a rebased pull request is not.
- Let the script append the
YYDDD.RUN_NUMBERbuild suffix to prereleases and report the environment, then pass the resulting value todotnet publishwith-p:Version.
Official guides: MinVer options and how MinVer works.
MinVer has no configuration file. Its documentation contrasts it with NBGV on exactly that point: NBGV encapsulates versioning in a config file, while MinVer reads tags plus one of three configuration surfaces — MSBuild properties for the MinVer package, environment variables for either package, or command-line options for minver-cli. This sample uses the CLI, so the options live in scripts/calculate-version.sh, the one place every caller runs:
| CLI option | Purpose in this strategy |
|---|---|
-t v | Recognizes RC and stable tags such as v1.0.0-rc.1 and v1.0.0. |
-m 1.0 | Keeps builds before the first release on the intended 1.0 line instead of 0.0. |
-p preview | Names untagged builds preview rather than MinVer's default prerelease identifier. |
Omit -i, --ignore-height | Makes successive merges unique and ordered. |
These 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. Because the script is the only place the options appear, the sample's tests fail when a workflow bypasses it and calls minver-cli directly.
Source: .github/workflows/minver.yml
- run: dotnet tool install --tool-path "$RUNNER_TEMP/minver" minver-cli --version 8.0.0
# minver-cli has no configuration file, so scripts/calculate-version.sh owns the
# options. Every caller runs that script, which keeps the strategy in one place.
- name: Calculate version
id: version
shell: bash
env:
MINVER_CLI: ${{ runner.temp }}/minver/minver
run: |
bash scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"
- run: dotnet publish src/minver-versioning-sample.csproj --configuration Release --no-restore --output publish -p:Version=${{ needs.calculate-version.outputs.version }}In its github-output mode the script emits the calculated version, the assembly version, the informational version, the deployment environment, and the commit, then appends the YYDDD.RUN_NUMBER build suffix to prereleases so each CI run is unique. Stable versions stay clean, and a tag ref that is neither an RC nor a stable release fails the job:
| Ref | Version | Environment |
|---|---|---|
refs/heads/main (after PR) | 1.0.0-preview.1.26123.42 | dev |
refs/tags/v1.0.0-rc.1 | 1.0.0-rc.1.26123.43 | staging |
refs/tags/v1.0.0 | 1.0.0 | production |
The suffix uses date -u +%y%j plus GITHUB_RUN_NUMBER, matching the GitVersion and NBGV samples. MinVer's height-zero version is 1.0.0-preview with no numeric suffix, so that baseline keeps environment none and never publishes. The .NET SDK receives the resulting value through -p:Version.
MinVer has no configuration file, so the script both declares the strategy and calls the tool:
Source: minver-versioning-sample/scripts/calculate-version.sh
# MinVer writes the calculated version to stdout and its log to stderr. A version tag
# at HEAD is returned exactly as tagged, so the tag is the release identity.
release_tags="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$' || true)"
# A stable tag outranks a prerelease tag when both point at the same commit, which is
# how a validated release candidate is promoted to stable. MinVer makes the same choice
# for the version, and this selection only decides the audience and the build suffix.
release_tag="$(printf '%s\n' "$release_tags" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)"
if [[ -z "$release_tag" ]]; then
release_tag="$(printf '%s\n' "$release_tags" | sort -V | tail -n 1 || true)"
fi
version="$($minver_cli "${minver_options[@]}" "$@")"
version="${version%%+*}"
if [[ "$output_mode" != "github-output" ]]; then
printf '%s\n' "$version"
exit 0
fiThe tool-specific line is the CLI call: minver_options is (-t v -m 1.0 -p preview) from the top of the same script, and minver-cli resolves the version from the tag at HEAD or from the nearest matching tag plus Git height. The script keeps MinVer's value as-is for a tagged commit, strips only the +metadata suffix, and leaves the release identity to the tool. Everything around it - the release-tag check, the CI build suffix, the environment mapping - is the shared adapter shape, which is why the suite asserts the script and minver-cli agree.
Height follows the first parent, not every commit
MinVer's height is not a raw commit count. When history diverges, MinVer follows the paths in the order the commit's parents are stored in Git, and the first parent is the branch that was checked out when the merge was performed. Height is therefore the number of first-parent commits after the version-source commit:
| Merge style | Five-commit pull request | Change per pull request |
|---|---|---|
Merge commit (--no-ff) | 1.0.0-preview → .1 → .2 | +1 |
| Squash and merge | 1.0.0-preview → .1 → .2 | +1 |
| Rebase and merge (or fast-forward) | 1.0.0-preview → .5 → .10 | +5 |
This is the opposite of GitVersion, whose preview number counts every commit reachable from HEAD. The same five-commit pull request merged with a merge commit advances MinVer by one and GitVersion by six.
That difference changes the advisory for each tool. GitVersion needs squash merges and a CI check that rejects merge commits; MinVer tolerates merge commits and only needs Allow rebase merging disabled in the repository pull-request settings. A rebased pull request leaves the same linear history as a squash merge, so MinVer's problem cannot be detected after the fact and no history check can catch it. The scenario tests cover the squash path and the merge-commit path and assert that both advance height by exactly one.
How MinVer handles the five phases
- Each explicit tag is used exactly on its tagged commit.
- Before the first tag, untagged merges produce
previewversions with increasing Git height. Afterv1.0.0-rc.1, MinVer produces an internal build such as1.0.0-rc.1.1until the next explicit tag. - The stable tag
v1.0.0produces1.0.0. - A later untagged commit defaults to the next patch prerelease line.
- Commit messages are ignored, so a
feat:afterv1.0.0still produces1.0.1-preview.1; starting a new minor line needs an edit to the-moption in the script, or a tag. - Preview and RC builds carry the
YYDDD.RUN_NUMBERsuffix in CI; stable builds do not. - Height advances by one per accepted pull request when pull requests are squash-merged or merged with a merge commit; rebase merging multiplies it by the number of commits in the pull request.
- 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. Its first-parent height is also more forgiving than GitVersion's commit count, at the cost of depending on a repository setting that no CI check can verify.
The same minimalism removes the release-intent check. MinVer cannot compare a tag against an expected version, so a mistyped v9.9.9 publishes 9.9.9 instead of being rejected, and a tag without the configured prefix is ignored rather than honoured. Guarding release tags therefore belongs to release review or a workflow step.
Sample files: minver-versioning-sample.csproj, scripts/calculate-version.sh, .github/workflows/minver.yml, 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
- Commit
version.jsonwith the initial development line, here1.0.0-preview.{height}. - Declare release refs through
publicReleaseRefSpec. - Install the pinned
nbgvCLI in CI. - Run the shared
calculate-version.shpolicy adapter for every main or release-tag build. - Let the adapter validate that a release tag matches NBGV's calculated base version.
- Pass the adapter's selected version and metadata to
dotnet publishwith-p:Versionand-p:InformationalVersion.
Official guides: version.json reference, versioning workflow, and cloud-build integration.
NBGV deliberately keeps more policy in source control than MinVer:
| Setting | Purpose in this strategy |
|---|---|
version: 1.0.0-preview.{height} | Declares the initial development line and exposes Git height. |
versionHeightOffset: -1 | Reserves preview.0 for validation of the initialization commit. |
versionHeightOffsetAppliesTo | Limits the bootstrap offset to the initial preview train. |
publicReleaseRefSpec | Identifies supported vMAJOR.MINOR.PATCH and -rc.N release refs. |
calculate-version.sh | Converts 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
{
"$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
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.
NBGV owns the calculation, so the adapter validates instead of deriving:
Source: nerdbank-versioning-sample/scripts/calculate-version.sh
nbgv_version="$("$nbgv_path" get-version -v SemVer2)"
effective_version="${nbgv_version%%.g*}"
release_tag="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$' | sort -V | tail -n 1 || true)"
if [[ -n "$release_tag" && "${release_tag#v}" != "$effective_version" ]]; then
echo "Release tag ${release_tag#v} does not match NBGV version $effective_version." >&2
exit 1
fi
if [[ "$output_mode" != "github-output" ]]; then
printf '%s\n' "$effective_version"
exit 0
fiThe adapter asks the CLI for the SemVer2 value and drops only the .g<commit> suffix that NBGV adds to every build, so the base version comes from version.json plus Git height. The release tag is then a check, not a source: when a tag at HEAD names a different version, the job fails instead of publishing an unreviewed number. That is the release-intent validation MinVer cannot perform, and it is why NBGV needs no separate expectation file.
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.0and 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.jsonto1.0.0-rc.{height}; the first RC merge calculates1.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
- Import
Sdk.propsandSdk.targetsfromMicrosoft.DotNet.Arcade.SdkinDirectory.Build.propsandDirectory.Build.targets. - Configure the Microsoft
dotnet-engNuGet feed, and map the SDK by name inglobal.json. - Commit the version inputs in
eng/Versions.props, which the SDK locates through$(VersionsPropsPath)and imports automatically. - Let Arcade's version targets assemble
Version; do not compute it by hand. - Supply
OfficialBuildIdfrom CI so Arcade treats the build as official and stamps it. - Read the release tag at
HEADand pass it through as the version, so a tagged release publishes exactly as tagged and unstamped. - Derive preview numbering from commit height and pass it as
PreReleaseVersionIteration. - Use explicit tags only for RC and stable events, and accept squash merges only, because Arcade counts commits rather than first parents.
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 input | Purpose in this strategy |
|---|---|
VersionPrefix | Holds the current stable core, initially 1.0.0; a reviewed edit advances the shared feature train. |
PreReleaseVersionLabel | preview while feature work lands, rc while a candidate is hardening. |
PreReleaseVersionIteration | Preview ordinal, supplied by the adapter from commit height, or the next RC ordinal taken from reachable tags. |
OfficialBuildId | <yyyyMMdd>.<revision> from CI; turns OfficialBuild on and produces the SHORT_DATE.revision stamp. |
DotNetFinalVersionKind | Set to release for a stable tag, so Arcade drops the prerelease suffix. |
Version | Assembled by Arcade from the inputs above and passed to the SDK and application build. |
This sample follows the Arcade layout for versioning: the inputs live in eng/Versions.props, the SDK is imported by name, and Arcade writes build output under artifacts/. It does not reproduce the full eng/common build-script layout, which a complete Arcade repository also uses.
Source: arcade-versioning-sample/eng/Versions.props
<PropertyGroup>
<VersionPrefix Condition="'$(VersionPrefix)' == ''">1.0.0</VersionPrefix>
<PreReleaseVersionLabel Condition="'$(PreReleaseVersionLabel)' == ''">preview</PreReleaseVersionLabel>
<PreReleaseVersionIteration Condition="'$(PreReleaseVersionIteration)' == ''">1</PreReleaseVersionIteration>
</PropertyGroup>Source: arcade-versioning-sample/Directory.Build.targets
<Import Project="Sdk.targets" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<PropertyGroup Condition="'$(ReleaseVersion)' != ''">
<Version>$(ReleaseVersion)</Version>
<PackageVersion>$(ReleaseVersion)</PackageVersion>
</PropertyGroup>The committed inputs state the train and the release phase, so an RC-preparation build can never fall back to preview. The shell adapter supplies what Arcade cannot derive on its own: preview ordinals from commit height, the next RC ordinal from reachable tags, and OfficialBuildId from CI. Arcade assembles and stamps the version; it is not the Git-history calculator.
The second block is what keeps a release tag exact. Arcade stamps every official build with SHORT_DATE.revision, and no supported input turns that off, so the adapter passes the tag through ReleaseVersion and this group replaces the stamped Version after the SDK has calculated it. Previews keep the stamp because no tag identifies them.
Source: .github/workflows/arcade.yml
- name: Calculate version
id: version
shell: bash
run: |
bash scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"The adapter runs the opposite way around from the other samples: it derives Arcade's inputs from Git, then asks Arcade to assemble the version.
Source: arcade-versioning-sample/scripts/calculate-version.sh
# Supplying OfficialBuildId turns OfficialBuild on inside the SDK (DefaultVersions.props).
# ContinuousIntegrationBuild stops Arcade from relabelling the version as 'dev' outside CI.
msbuild_properties=(
-p:ContinuousIntegrationBuild=true
-p:OfficialBuildId="$official_build_id"
-p:PreReleaseVersionLabel="$label"
-p:PreReleaseVersionIteration="$iteration"
)
if [[ -n "$final_version_kind" ]]; then
msbuild_properties+=(-p:DotNetFinalVersionKind="$final_version_kind")
fi
if [[ -n "$release_version" ]]; then
# The official build still happens; only the version string comes straight from the tag.
msbuild_properties+=(-p:ReleaseVersion="$release_version")
fi
# Arcade computes the version; this adapter only reads it back.
version="$(dotnet msbuild version.proj -getProperty:Version "${msbuild_properties[@]}" | tr -d '\r' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+' | tail -n 1)"The script reads the committed train from eng/Versions.props, derives the preview ordinal from commit height and the RC ordinal from reachable tags, and passes them with OfficialBuildId as MSBuild properties. dotnet msbuild version.proj -getProperty:Version then returns whatever Arcade's own version targets assembled, so the adapter reports Arcade's result instead of formatting a version string itself. A tagged release supplies ReleaseVersion, which Directory.Build.targets uses to replace the stamped value and keep the tag exact.
The remaining configuration wires the SDK in and points NuGet at the engineering feed. Directory.Build.props imports Sdk.props so the toolset and version targets are in place before any repository customization:
Source: arcade-versioning-sample/Directory.Build.props
<Project>
<Import Project="Sdk.props" Sdk="Microsoft.DotNet.Arcade.Sdk" />
<PropertyGroup>
<RepositoryRoot Condition="'$(RepositoryRoot)' == ''">$(MSBuildThisFileDirectory)</RepositoryRoot>
</PropertyGroup>
</Project>global.json pins the .NET SDK and maps the Arcade SDK by name, which is what lets Sdk.props and Sdk.targets resolve without a package reference:
Source: arcade-versioning-sample/global.json
{
"sdk": {
"version": "10.0.102"
},
"msbuild-sdks": {
"Microsoft.DotNet.Arcade.Sdk": "10.0.0-beta.24475.5"
}
}NuGet.config adds the dotnet-eng feed where the SDK is published. NuGet discovers it from the sample directory, so the workflows do not add the source again:
Source: arcade-versioning-sample/NuGet.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key="dotnet-eng" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-eng/nuget/v3/index.json" />
</packageSources>
</configuration>version.proj is an evaluation-only project. A project without an SDK does not import Directory.Build.props, so it imports both files explicitly and gives the adapter a project whose Version property Arcade can compute:
Source: arcade-versioning-sample/version.proj
<Project>
<Import Project="Directory.Build.props" />
<Import Project="Directory.Build.targets" />
</Project>How Arcade handles the five phases
- Arcade's own version targets assemble
Versionfrom the committed inputs and the CI build id. - The adapter, not Arcade, maps untagged builds to numbered previews by deriving
PreReleaseVersionIterationfrom commit height. - Untagged preview builds carry Arcade's
SHORT_DATE.revisionstamp; RC preparation and fixes stay in the RC phase until the release label changes. Tags authorize publication. - A release tag is passed through unchanged, so
1.0.0-rc.1publishes as exactly1.0.0-rc.1with no stamp. DotNetFinalVersionKind=releasemakes Arcade drop the suffix and produce the clean1.0.0.- Because Arcade counts commits rather than following first parents, the workflow requires squash merges; a merge commit advances the preview number by every commit it brought.
- Full history is still required, because the adapter derives the preview ordinal from commit height and the RC ordinal from reachable tags.
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, eng/Versions.props, Directory.Build.targets, version.proj, 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 keeps no release-intent file. The commit history is the intent: @semantic-release/commit-analyzer decides the base version, so a feat: after a stable tag opens the next minor line with no reviewed preparation commit and no bootstrap anchor.
Its default release mapping is consumer-impact based: fix produces a patch, feat produces a minor, and the BREAKING CHANGE footer produces a major. The default preset is angular, and that detail matters: a feat!: header is not a breaking marker under it, so the footer is the only reliable form for a major release. Official semantic-release prerelease support normally uses dedicated prerelease branches such as beta. This repository keeps the no-release-branch policy, because the promotion model is a tag on main. semantic-release therefore calculates the base on main, and the adapter supplies the release identity. That adaptation should be understood rather than hidden.
The adapter also has to supply the preview ordinal. semantic-release's own prerelease numbering needs a release branch beside the prerelease branch, so a single-branch repository cannot use it. Counting commits since the nearest non-candidate release tag reproduces the GitVersion shape instead.
semantic-release setup
- Add
semantic-releaseand the commit analyzer plugin topackage.json. - Configure
branches: ['main']andtagFormat: 'v${version}'. - Enforce Conventional Commit pull-request titles, because a squash-commit title is now the release decision. Keep the squash-only repository setting for the same reason.
- Read
branches,tagFormat, and the configured options ofcommit-analyzerfromrelease.config.cjs, so the config file stays the single source of the strategy. - Call the JavaScript API with
dryRun: trueto retrievenextRelease.version, loading only the analyzer so the calculation needs no token. - Count commits since the nearest non-candidate release tag for the preview ordinal.
- Accept explicit SemVer tags only for RC and stable events, and publish those exactly as tagged.
- Pass the final value into
dotnet publish. - Keep Release Drafter as the human-reviewed GitHub release-note source.
Official guides: configuration, workflow configuration, and JavaScript API.
semantic-release is a Node.js tool, so its configuration starts with package.json. The dependencies pin the orchestrator and the four plugins the config uses, and "type": "module" lets the analyzer script use ES modules:
Source: semantic-release-versioning-sample/package.json
{
"name": "semantic-release-dotnet-versioning-sample",
"private": true,
"type": "module",
"devDependencies": {
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/github": "^12.0.0",
"@semantic-release/release-notes-generator": "^14.1.0",
"semantic-release": "^25.0.9"
}
}release.config.cjs holds the release strategy. The adapter and the tests read branches, tagFormat, and the commit-analyzer options from this file rather than repeating them:
Source: 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 plugin | Purpose 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-analyzer | Maps Conventional Commits to the next base version, and returns nothing when no commit warrants a release. |
@semantic-release/release-notes-generator | Calculates notes during dry run; Release Drafter remains the reviewed GitHub release-note source. |
@semantic-release/exec | Demonstrates how semantic-release can pass ${nextRelease.version} into dotnet publish. |
@semantic-release/github | Supplies GitHub integration when semantic-release owns publication; our workflow keeps final control. |
Source: semantic-release-versioning-sample/scripts/calculate-version.mjs
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import semanticRelease from 'semantic-release'
const require = createRequire(import.meta.url)
const config = require(fileURLToPath(new URL('../release.config.cjs', import.meta.url)))
const analyzerName = '@semantic-release/commit-analyzer'
const analyzer = config.plugins.find((plugin) =>
Array.isArray(plugin) ? plugin[0] === analyzerName : plugin === analyzerName
)
const result = await semanticRelease(
{
branches: config.branches,
tagFormat: config.tagFormat,
plugins: [analyzer ?? analyzerName],
ci: false,
dryRun: true,
},
{
cwd: process.cwd(),
env: process.env,
stderr: process.stderr,
stdout: process.stderr,
}
)
// No output means semantic-release declared no release for these commits.
process.stdout.write(result?.nextRelease?.version ?? '')Source: .github/workflows/semantic-release.yml
- name: Calculate version
id: version
shell: bash
run: |
bash scripts/calculate-version.sh github-output | tee -a "$GITHUB_OUTPUT"The script combines the analyzer's base version with a Git-derived preview ordinal:
Source: semantic-release-versioning-sample/scripts/calculate-version.sh
if [[ -n "$release_tag" ]]; then
version="${release_tag#v}"
else
# The commit analyzer configured in release.config.cjs is the strategy. It returns the
# next base version, and nothing at all while no commit warrants a release.
base_version="$(node scripts/calculate-version.mjs | tr -d '\r')"
# The nearest reachable release tag is the train baseline. A release candidate is a
# candidate rather than a train, so an rc tag does not reset the preview counter.
train_tag="$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-rc.*' HEAD 2>/dev/null || true)"
if [[ -n "$train_tag" ]]; then
height="$(git rev-list --count "$train_tag..HEAD")"
else
height="$(($(git rev-list --count HEAD) - 1))"
fi
# semantic-release declares no release for commits such as `chore:` or `docs:`, so the
# shipped line is kept and only the preview ordinal moves. Before anything has shipped
# the fallback is semantic-release's first release of 1.0.0.
if [[ -z "$base_version" ]]; then
base_version="${train_tag#v}"
base_version="${base_version:-1.0.0}"
fi
version="$base_version-preview.$height"
fiA release tag short-circuits the whole calculation and is used as written. Otherwise the script calls the analyzer in dry-run mode for the base version: when the analyzer names one, that name is kept, and when it declares no release (chore:, docs:) the previous train version is reused so the shipped line stays put. The preview ordinal is a plain commit count from the nearest non-candidate tag, because an rc tag is a candidate rather than a train. That is why an untagged fix after a candidate returns to the preview train instead of continuing an RC channel.
How semantic-release handles the five phases
- Conventional Commits determine the next base, such as
1.0.0, and declare no release when no commit warrants one. - The base is read from the analyzer in dry run, so the strategy stays in
release.config.cjsinstead of a hand-written version file. - The preview ordinal counts commits since the nearest non-candidate release tag, and CI adds the date/revision stamp on top.
- Explicit
rc.Nand stable tags are published exactly as tagged, with no stamp, and only the tag changes the target audience. @semantic-release/execcan rundotnet 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 reads the version decision during a dry run and keeps publication in the workflow, so semantic-release never publishes on its own; only the tag does.
Local scenario tests supply no run numbers: the shell adapter counts commits since the nearest non-candidate release tag, which is the ordinal recorded in preview.N. CI adds the date/revision stamp on top for preview artifacts. Because the analyzer decides the base, a feat: after a stable tag opens the next minor train with no reviewed intent commit, a chore: or docs: commit declares no release at all and keeps the shipped line, and only an explicit tag produces rc.N or a stable version. An untagged fix after a release candidate therefore returns to the preview train instead of continuing an RC channel, and only the tag promotes a build to the staging audience.
Sample files: package.json, release.config.cjs, scripts/calculate-version.mjs, scripts/calculate-version.sh, and tests/semantic-release-versioning-sample.Tests.
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 publishes preview drafts from main and publishes prerelease or stable artifacts from tags on main. 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:
- Commit the chosen tool's version intent and configure the initial baseline. GitVersion's baseline tag and Arcade's committed inputs are sample-specific; they are not public releases. Confirm the repository's merge strategy at the same time, because Git-height counters advance by every commit that lands and only squash merging keeps one pull request equal to one preview step.
- Run the helper-driven scenario where the tool has a helper and the direct-input scenario everywhere. Inspect calculated versions and tags separately, including the RC fix and the first three previews of the next train.
- Preserve the CI output contract:
version,assembly_version,informational_version,environment, andcommit. Every sample's adapter emits all five, so thecalculate-versionjob exposes them and the workflow passesversionto the SDK with-p:Version. Decide explicitly whether artifact formatting stays short or retains the first article's date/revision suffix. - 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.
- 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?
| Tool | Primary source of truth | Best fit | Main tradeoff |
|---|---|---|---|
| GitVersion | Git graph, branch rules, tags | Branch-aware repositories | Most policy and configuration; preview numbering expects squash merges |
| MinVer | Version tags | Simple tag-driven libraries/apps | No config file, so options live in a wrapper script; rebase merging must be off |
| NBGV | version.json plus commit identity | Reproducible version per commit | Release promotion differs from pure tag-first flow |
| Arcade | MSBuild/repository engineering properties | Repositories adopting dotnet engineering infrastructure | Much broader than version calculation |
| semantic-release | Conventional Commits and release history | Multi-ecosystem automated releases | Node 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. Three responsibilities are shared, although the method names differ by sample:
- A helper-driven scenario prepares intent and creates tags through
release-version.sh. NBGV covers this withRelease_helper_commands_follow_release_scenario. - A direct-input scenario prepares the same history without the helper and verifies the tool or adapter that owns the calculation.
- A mismatched or unsupported tag is rejected, so an unrelated tag such as
v9.9.9cannot silently replace the intended release version. NBGV asserts this withMismatched_manual_tag_is_rejectedand Arcade withMismatched_release_tag_is_rejected, while GitVersion, MinVer, and semantic-release assertUnsupported_release_tag_is_rejectedfor a tag ref that is neither an RC nor a stable release, and also assert that any well-formed tagged version is honoured as written.
GitVersion, MinVer, Arcade, and semantic-release diverge from that shape, because none of them has a helper to drive. GitVersion, MinVer, and semantic-release additionally have no release-intent file, while Arcade keeps its intent in eng/Versions.props, so its suite covers the committed inputs and the tag pass-through instead of helper commands. GitVersion's suite covers preview versions from main commits, release versions from tags, the next-minor train driven by Conventional Commits, the commit-count merge path, the CI suffix, rejection of an unsupported tag, and that both workflows run the script instead of calling dotnet-gitversion directly. Its tag-prefix option accepts a bare or upper-case tag as a base version, so it asserts that v9.9.9 publishes 9.9.9. MinVer's suite asserts the tag-driven scenario, the first-parent merge-commit path, that a commit message does not start a new train, that a tag without the configured v prefix is ignored rather than honoured, the CI suffix, rejection of an unsupported tag ref, that any well-formed tagged version is honoured as written, and that both workflows run the script instead of calling minver-cli directly. Arcade's suite asserts the tag-driven scenario across a committed rc phase change, that release intent comes from eng/Versions.props rather than commit messages, that a tag without the v prefix is ignored, that a mismatched tag is rejected, that Arcade derives its own SHORT_DATE.revision stamp from OfficialBuildId, that previews are stamped while tagged releases are not, that an untagged commit after an RC targets the dev audience, the commit-count merge path, and that both workflows run the script. semantic-release's suite asserts the tag-driven scenario, that commit messages start a new train, that a feat!: header is not a breaking marker, that non-release commits declare no release, that a tag without the configured v prefix is ignored, that any well-formed tagged version is honoured as written, the commit-count merge path, that the CI suffix stamps previews while RC and stable tags stay exact, that an untagged fix after a release candidate targets the dev audience, rejection of an unsupported tag ref, and that both semantic-release workflows run the script. The v9.9.9 assertion is the inverse of Mismatched_manual_tag_is_rejected: GitVersion, MinVer, and semantic-release have no expected version to compare against, so a well-formed tag publishes as written rather than failing, while Arcade validates the tag against the committed VersionPrefix and rejects it. GitVersion's, MinVer's, and semantic-release's suites also run the script and the native tool side by side and assert they agree, which catches the drift a configuration file would have prevented. Arcade's suite compares the adapter with Arcade's own version targets, so it proves the adapter reports what the SDK calculated rather than inventing a version.
The complete local sequence includes the next train, not only the stable release:
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.3MinVer'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. GitVersion's, MinVer's, and NBGV's CI build suffixes, Arcade's committed inputs and tag pass-through, and semantic-release's preview ordinal and CI suffix are wrapper-owned behavior that the native tools do not provide. A passing wrapper assertion is not evidence of native-tool equivalence. MinVer's script owns only the options and the CI suffix, so its suite runs the script and minver-cli side by side and asserts they agree, and Arcade's suite likewise asserts that the adapter and Arcade's own version targets produce the same version.
Run the complete suite from the sample repository root:
dotnet test microsoft-style-dotnet-versioning-samples.slnx --configuration ReleaseThese tests cover local version calculation, preparation commands, tag selection, and, for the wrapper-owned samples, the dated CI artifact suffix computed from a supplied run number. They do not exercise 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
- GitVersion GitHub Flow
- MinVer
- Nerdbank.GitVersioning workflow
- Arcade SDK
- semantic-release workflow configuration
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.


