27 min readMehdi Hadeli

Microsoft-Style .NET Versioning Strategy with SemVer and Nerdbank.GitVersioning

Sample code is available in the versioning-samples directory of the sample repository.

Introduction

A .NET application needs more than a number in an assembly. The team needs to know which commit produced an artifact, whether consumers may install it, what compatibility promise the number makes, and whether a release is still being evaluated.

The strategy in this article is best described as GitHub Flow with continuous preview versioning and tag-gated releases. It combines four established ideas:

  1. GitHub Flow provides one long-lived default branch and short-lived pull-request branches.
  2. Semantic Versioning 2.0.0 defines MAJOR.MINOR.PATCH, prerelease syntax, and precedence.
  3. Ordinary merges to main create previews, while release-intent merges prepare release-candidate and stable commits for explicit Git tags.
  4. Continuous delivery builds every accepted change, while a tag and CI policy decide what becomes a public release.

Nerdbank.GitVersioning (NBGV) is the implementation chosen for this sample. The strategy is not tied to NBGV, however. GitVersion, MinVer, Arcade, or semantic-release could implement the same policy: main is releasable, SemVer communicates compatibility, CI creates previews from accepted changes, tags select RC and stable commits, and CI builds the selected source once.

This article uses NBGV for the complete application workflow. The follow-up, Comparing .NET Versioning Tools, keeps this release contract and compares how GitVersion, MinVer, NBGV, Arcade, and semantic-release calculate and publish the same release stages.

The sample applies this model to a .NET 10 containerized web application using NBGV, GitHub Actions, Docker, GitHub Container Registry, and Release Drafter. NBGV keeps version intent in the committed version.json file and derives preview and RC ordinals from Git commit height. CI adds a date and build revision to published preview and RC artifacts, while stable release tags remain exact NBGV versions.

Why use this strategy for a .NET application?

Applications and libraries have different consumers, but both benefit from a traceable release contract. This sample focuses on an application, while the same versioning policy can be applied to NuGet packages.

For an application, the version connects a deployment or downloadable binary to its source commit. A preview can go to development or QA, an RC can go to staging and a wider test group, and a reviewed stable-preparation commit can go to production.

For a library, the package version is part of the public API contract. A prerelease suffix makes NuGet treat the package as prerelease, so consumers must opt in. The same NBGV principles can produce preview packages for early adopters and stable packages for all consumers.

The strategy gives this sample six practical properties:

  • Every published version maps to one source commit.
  • Preview consumers opt into change while the application is still being evaluated.
  • RC consumers validate a feature-complete candidate before production publication.
  • Stable versions contain no prerelease suffix and sort after their candidates.
  • CI, not a developer workstation, creates release artifacts from the selected source.
  • Release notes are assembled from reviewed pull requests before publication.

Microsoft's .NET preview API guidance describes prerelease packages as a way to collect early-adopter feedback and notes that preview APIs may change. This sample borrows that lifecycle language for application artifacts, but it does not claim that every application RC is supported by Microsoft. Support terms remain a product decision.

How .NET version fields fit

A .NET SDK project exposes several related version fields. They serve different consumers, so a release pipeline should not update all of them indiscriminately:

FieldPurpose in this strategy
PackageVersionPublic NuGet identity, such as 1.0.0-rc.1. Follow SemVer.
VersionBuild-level product version. This sample keeps it aligned with the published artifact version.
AssemblyVersionRuntime assembly identity. Libraries may change it less frequently to reduce binding disruption.
FileVersionFour-part operating-system file version. A CI build number can provide the revision.
InformationalVersionHuman-readable version plus source identity, such as 1.0.0-rc.1+abc123.

NBGV calculates the version metadata from the committed version.json and Git history. The workflow then passes the selected publish version to the .NET SDK, while the informational version and commit identity remain available for diagnostics. Build metadata after +, such as a commit SHA, improves traceability but does not affect SemVer precedence. Do not use 1.0.0+build.20 and 1.0.0+build.21 as ordered package releases; use a prerelease number or a new patch version when ordering matters.

Microsoft-style package versions

The version format follows the pattern used by Microsoft packages such as Microsoft.EntityFrameworkCore on NuGet:

10.0.0-preview.7.25380.108
10.0.0-rc.2.25502.107
10.0.0

The first three numeric components are the release version. preview or rc identifies the prerelease stage, the next number is the stage ordinal, the five-digit component is a compact UTC date (YYDDD), and the final component is the build revision. Stable packages omit the prerelease identifiers.

This sample adopts the same SemVer shape for CI-published artifacts:

1.0.0-preview.N.YYDDD.RUN_NUMBER
1.0.0-rc.N.YYDDD.RUN_NUMBER
1.0.0

NBGV calculates N from Git history. GitHub Actions supplies YYDDD from the UTC build date and RUN_NUMBER as the monotonically increasing workflow revision. Release tags remain short and reviewable, such as v1.0.0-rc.1 and v1.0.0; CI validates those tags against NBGV before adding the build suffix to the published artifact.

Our repository adds its own operational policy around those familiar names:

  • Ordinary feature merges create untagged preview artifacts in CI, starting at preview.1.
  • The initial repository setup calculates preview.0 for validation only and is not published.
  • Release-preparation merges validate RC or stable intent without publishing until their matching tag is pushed.
  • An approved commit receives an explicit RC tag.
  • A later approved stable-preparation commit receives the stable tag.
  • Release Drafter controls draft, prerelease, and stable GitHub release states.

The release contract

main is the only long-lived branch. Feature branches merge through pull requests, and releases are created by tagging commits on main.

The reference release line is:

1.0.0-preview.1.YYDDD.RUN_NUMBER
1.0.0-preview.2.YYDDD.RUN_NUMBER
v1.0.0-rc.1 -> 1.0.0-rc.1.YYDDD.RUN_NUMBER
v1.0.0-rc.2 -> 1.0.0-rc.2.YYDDD.RUN_NUMBER
v1.0.0 -> 1.0.0

The preview and RC counters are calculated by NBGV from Git history, so preview tags are unnecessary and RC numbers are not typed manually. RC and stable tags are release decisions that must point to commits whose calculated NBGV versions match the tag. CI appends YYDDD.RUN_NUMBER to the published RC artifact, just as it does for previews. Moving from RC to stable changes version.json, so the merged stable-preparation commit is validated as 1.0.0.g<commit> and the stable tag points to that commit rather than the RC2 commit. The tag build calculates clean 1.0.0 and publishes it without a date or run suffix.

One source of truth

versioning-samples/version.json is the version intent, and NBGV is the version calculator. Local development and CI use the same command:

dotnet nbgv get-version -v SemVer2

The sample starts with this configuration:

{
  "version": "1.0.0-preview.{height}",
  "versionHeightOffset": -1,
  "versionHeightOffsetAppliesTo": "1.0.0-preview.{height}",
  "publicReleaseRefSpec": ["^refs/tags/v\\d+\\.\\d+\\.\\d+(?:-rc\\.\\d+)?$"],
  "cloudBuild": {
    "setVersionVariables": true
  }
}

The {height} placeholder gives each preview a deterministic number. The -1 offset is intentional: it reserves the first calculated height for the initial repository setup commit. NBGV reports that commit as preview.0, but CI validates it without publishing it. The first ordinary feature merge is therefore the first published preview, preview.1. versionHeightOffsetAppliesTo limits that adjustment to the initial 1.0.0-preview.{height} template. Later preview trains remove the offset, so the commit that starts 1.1.0 calculates 1.1.0-preview.1 and its first feature merge calculates 1.1.0-preview.2. When the version changes to 1.0.0-rc.{height}, the offset also no longer applies. The RC-preparation merge therefore calculates 1.0.0-rc.1, and the next merged RC fix calculates 1.0.0-rc.2.

For published previews, CI keeps that NBGV ordinal and appends an EF Core-style date and build revision: 1.0.0-preview.2.YYDDD.RUN_NUMBER. YYDDD is the UTC two-digit year and day of year, and RUN_NUMBER is GitHub Actions' monotonically increasing workflow run number. This suffix is added only to preview and RC artifacts; stable tags continue to use the exact NBGV version.

If the initialization commit should itself be the first published preview, set versionHeightOffset to 0. The sequence then becomes preview.1 for the initialization commit, preview.2 for the first feature, and preview.3 for the second feature. This removes the validation-only preview.0 step, but it also changes the meaning of every later preview number.

publicReleaseRefSpec identifies refs that NBGV treats as public releases; it does not read an arbitrary tag and use it to replace the version calculated from version.json and Git height. A release tag must match the calculated base version. On an unrecognized ref, NBGV may append .g<commit> to identify the commit; the workflow removes only that suffix for tag comparison and rejects a tag that disagrees with the base version. For example, an untagged RC-preparation commit may report 1.0.0-rc.1.g<commit> for validation, while v1.0.0-rc.1 is the clean public tag and CI publishes 1.0.0-rc.1.YYDDD.RUN_NUMBER to staging.

Version meanings by environment

Ref or actionVersionEnvironmentRelease behavior
Start preview train on main1.0.0-preview.0validationRun CI only; do not publish an image
Merge first feature into main1.0.0-preview.1.YYDDD.RUN_NUMBERdevPublish Docker image; keep GitHub release as draft
Merge second feature into main1.0.0-preview.2.YYDDD.RUN_NUMBERdevPublish the next preview image and update the draft
Merge RC version intent1.0.0-rc.1.g<commit>validationRun CI only; do not publish an image
Tag the merged RC commitv1.0.0-rc.1 -> 1.0.0-rc.1.YYDDD.RUN_NUMBERstagingPublish Docker image and Release Drafter release
Merge stable version intent1.0.0.g<commit>validationRun CI only; do not publish an image
Tag the merged stable commitv1.0.0 -> 1.0.0productionPublish clean Docker image and Release Drafter release

RC and stable changes are prepared on short-lived branches and merged through the protected main branch before tagging. The preview-train start commit and RC/stable preparation merges run validation only. Ordinary preview merges from preview.1 onward publish to dev; CI formats those artifacts as X.Y.Z-preview.N.YYDDD.RUN_NUMBER. An approved RC tag such as v1.0.0-rc.1 publishes X.Y.Z-rc.N.YYDDD.RUN_NUMBER to staging. Stable tags publish the exact stable version. The supported path is release-version.sh tag, which calls nbgv tag after the version change has merged. CI also compares every syntactically valid vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N release tag with nbgv get-version and rejects a mismatch.

Commit-height previews

The sample uses the same release train with an automatic height:

1.0.0-preview.1.YYDDD.RUN_NUMBER
1.0.0-preview.2.YYDDD.RUN_NUMBER
1.0.0-preview.3.YYDDD.RUN_NUMBER

The first preview identifier is the NBGV commit height. Published CI previews also include a UTC YYDDD date component and the GitHub Actions run number, following the familiar EF Core-style format. The short commit SHA remains in assembly informational metadata and container provenance.

Ordinary feature, bug-fix, test, and documentation PRs do not modify versioning-samples/version.json. Each merge to main advances the height. To start a new release train, update the base version once:

./release-version.sh prepare-train 1.1.0

The helper changes version.json to 1.1.0-preview.{height}. Review, commit, and push that release-train change before merging more feature work.

Release helper commands

The sample helper at versioning-samples/release-version.sh supports these commands:

Source: versioning-samples/release-version.sh

versioning-samples/release-version.sh
./release-version.sh prepare-train 1.1.0
./release-version.sh prepare-rc 1.0.0
./release-version.sh prepare-stable 1.0.0
./release-version.sh tag

The prepare commands call dotnet nbgv set-version to edit versioning-samples/version.json. They do not commit or push. Review and commit the version change first.

This helper provides one named command for each intentional version change. It does not maintain an RC counter. Both previews and RCs use NBGV's {height} placeholder, so the Git history determines preview.1, preview.2, rc.1, and rc.2. The script remains a convenience layer over standard NBGV commands:

Helper commandNBGV operationEffect
./release-version.sh prepare-train 1.1.0Set 1.1.0-preview.{height}Starts the next preview train and removes the bootstrap offset.
./release-version.sh prepare-rc 1.0.0Set 1.0.0-rc.{height}Starts the RC train; later RC fixes need no version-file edit.
./release-version.sh prepare-stable 1.0.0Set 1.0.0Prepares stable intent and removes height-offset properties.
./release-version.sh tagdotnet nbgv tagCreates the local v{version} tag from the committed version.

The script also checks that dotnet is available, validates the number of arguments for each command, and stops when a command fails. That prevents common typing mistakes while keeping the actual version calculation in NBGV. It does not hide the important release decisions: inspect the changed version.json, run validation, commit and push the change, and push the generated tag explicitly.

For the first RC, create a release-preparation branch because main is protected. The manual path changes version intent to the RC height template:

git switch -c chore/prepare-1.0.0-rc
dotnet nbgv set-version "1.0.0-rc.{height}"
git add version.json
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 pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
# version may be 1.0.0-rc.1.g<commit> before the public tag exists
dotnet nbgv tag
git push origin v1.0.0-rc.1

The helper replaces only the version-preparation command:

git switch -c chore/prepare-1.0.0-rc
./release-version.sh prepare-rc 1.0.0
git add version.json
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 pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
./release-version.sh tag
git push origin v1.0.0-rc.1

An RC fix follows the normal protected-branch flow. Because version.json remains on 1.0.0-rc.{height}, the squash-merge commit advances the calculated version to 1.0.0-rc.2. Do not run prepare-rc again:

git switch -c fix/release-candidate
git add -A
git commit -m "fix: correct release candidate behavior"
git push -u origin fix/release-candidate
# Open and squash-merge the pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
# version may be 1.0.0-rc.2.g<commit> before the public tag exists
./release-version.sh tag
git push origin v1.0.0-rc.2

Starting the next preview train also uses a release-preparation branch:

git switch -c chore/prepare-1.1.0-preview
./release-version.sh prepare-train 1.1.0
git add version.json
git commit -m "chore: start 1.1.0 preview train"
git push -u origin chore/prepare-1.1.0-preview
# Open and squash-merge the pull request.

In both examples, the helper makes the versioning operation easier and less error-prone; it does not decide whether the change is approved, create the commit, or push to GitHub. Those steps remain explicit so the release operator can review the generated version.json change and tag the exact approved commit.

Here is the complete helper from the sample repository:

versioning-samples/release-version.sh
#!/usr/bin/env bash
 
set -euo pipefail
 
usage() {
  cat <<'EOF'
Usage:
  ./release-version.sh prepare-train <major.minor.patch>
  ./release-version.sh prepare-rc <major.minor.patch>
  ./release-version.sh prepare-stable <major.minor.patch>
  ./release-version.sh tag
 
Examples:
  ./release-version.sh prepare-train 1.1.0
  ./release-version.sh prepare-rc 1.0.0
  ./release-version.sh prepare-stable 1.0.0
  ./release-version.sh tag
EOF
}
 
if [[ $# -lt 1 ]]; then
  usage
  exit 1
fi
 
if ! command -v dotnet >/dev/null 2>&1; then
  echo "dotnet is required. Restore the repository tools with: dotnet tool restore" >&2
  exit 1
fi
 
prepare_train() {
  local base_version="$1"
 
  dotnet nbgv set-version "${base_version}-preview.{height}"
  clear_version_height_offset
  echo "Updated version.json to ${base_version}-preview.{height}. Commit and push this release-train change."
}
 
prepare_rc() {
  local base_version="$1"
 
  dotnet nbgv set-version "${base_version}-rc.{height}"
  echo "Updated version.json to ${base_version}-rc.{height}. Commit and push this RC change."
}
 
prepare_stable() {
  local base_version="$1"
 
  dotnet nbgv set-version "$base_version"
  clear_version_height_offset
  echo "Updated version.json to ${base_version}. Commit and push this stable release change."
}
 
clear_version_height_offset() {
  local temporary_file
 
  temporary_file="$(mktemp)"
  awk '
    /"versionHeightOffset":/ { next }
    /"versionHeightOffsetAppliesTo":/ { next }
    { print }
  ' version.json > "$temporary_file"
  mv "$temporary_file" version.json
}
 
tag_release() {
  dotnet nbgv tag
  echo "Created the NBGV tag. Push it with: git push origin <tag-name>"
}
 
case "$1" in
  prepare-train)
    [[ $# -eq 2 ]] || { usage; exit 1; }
    prepare_train "$2"
    ;;
  prepare-rc)
    [[ $# -eq 2 ]] || { usage; exit 1; }
    prepare_rc "$2"
    ;;
  prepare-stable)
    [[ $# -eq 2 ]] || { usage; exit 1; }
    prepare_stable "$2"
    ;;
  tag)
    [[ $# -eq 1 ]] || { usage; exit 1; }
    tag_release
    ;;
  *)
    usage
    exit 1
    ;;
esac

Run tag on the exact approved commit. It calls dotnet nbgv tag, which creates the v{version} tag locally from the committed version. The script does not push the tag, so push it explicitly:

./release-version.sh tag
git push origin v1.0.0-rc.1

GitHub Actions workflow

The sample uses one workflow file for preview and tagged releases:

Source: versioning-samples/.github/workflows/build-and-publish.yml

versioning-samples/.github/workflows/build-and-publish.yml
on:
  pull_request:
  push:
    branches: [main]
    tags: ['v*']

The checkout must include the complete Git history and tags:

- uses: actions/checkout@v4
  with:
    fetch-depth: 0

NBGV uses commit height, version intent, and release tags to calculate the result. A shallow checkout can hide the commits or tags that establish the version baseline, producing a different version in CI from the one calculated locally. Keep fetch-depth: 0 in every version-calculating job.

The workflow has three jobs:

  1. build-test checks out full history, restores, builds, and tests on every pull request and push.
  2. calculate-version runs only for main and v* tags. It calculates NBGV SemVer2 for previews, or uses the release tag as the effective RC/stable version.
  3. publish runs for preview versions from preview.1 onward on main or for release tags. It pushes the Docker image, reports the selected environment, and updates or publishes Release Drafter.

Version calculation

The workflow calculates the version with NBGV for every publishable ref. For a recognized release tag, it removes the v prefix, removes only NBGV's non-public .g<commit> suffix for comparison, and then selects the deployment environment:

Source: versioning-samples/.github/workflows/build-and-publish.yml

versioning-samples/.github/workflows/build-and-publish.yml
nbgv_version="$($RUNNER_TEMP/nbgv/nbgv get-version -v SemVer2)"
version="$nbgv_version"
if [[ "${GITHUB_REF}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
  version="${GITHUB_REF_NAME#v}"
  nbgv_tag_version="${nbgv_version%%.g*}"
  if [[ "${version}" != "${nbgv_tag_version}" ]]; then
    echo "Release tag ${version} does not match NBGV version ${nbgv_tag_version}."
    exit 1
  fi
  if [[ "${version}" == *-rc.* ]]; then
    environment="staging"
  else
    environment="production"
  fi
elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
  if [[ "${version}" == *-preview.* ]]; then
    if [[ "${version}" =~ -preview\.0(\.|$) ]]; then
      environment="none"
    else
      environment="dev"
    fi
  else
    environment="none"
  fi
else
  echo "Unsupported release tag: ${GITHUB_REF_NAME}."
  exit 1
fi

The calculate-version job installs NBGV 3.10.94 into the runner. preview.0 on main selects none, so the train-start commit is validated without publication. preview.1 and later select dev, and CI formats them as preview.N.YYDDD.RUN_NUMBER. An untagged RC or stable preparation merge also selects none. v1.0.0-rc.1 selects staging and publishes rc.1.YYDDD.RUN_NUMBER, while v1.0.0 selects production and remains unsuffixed. The job also produces the assembly version, short commit SHA, informational version, Docker-safe tag, image name, and target environment as outputs consumed by the publish job.

main preview merges from preview.1 onward publish an image for dev with the preview.N.YYDDD.RUN_NUMBER format. The preview-train start commit and release-preparation commits are validation-only; the matching RC or stable tag triggers staging or production publication. The final deployment step currently reports the selected image and environment. Connect it to a deployment platform before treating those environments as live, and protect the production environment with required reviewers.

Release Drafter integration

Release Drafter has one configuration file:

versioning-samples/.github/release-drafter.yml

It is not a second workflow. The action runs inside the single publish job so release notes and Docker publication use the same calculated NBGV version:

Source: versioning-samples/.github/workflows/build-and-publish.yml

versioning-samples/.github/workflows/build-and-publish.yml
- name: Update or publish Release Drafter release
  if: always()
  uses: release-drafter/release-drafter@v7
  with:
    config-name: release-drafter.yml
    version: ${{ needs['calculate-version'].outputs.version }}
    name: v${{ needs['calculate-version'].outputs.version }}
    tag: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || format('v{0}', needs['calculate-version'].outputs.version) }}
    publish: ${{ startsWith(github.ref, 'refs/tags/v') && !contains(needs['calculate-version'].outputs.version, '-preview.') }}
    prerelease: ${{ contains(needs['calculate-version'].outputs.version, '-') }}

On main, Release Drafter updates one rolling preview draft with publish: false. On an RC or stable tag, it publishes the matching release. if: always() preserves the draft update if Docker publication or deployment fails; the workflow still fails and must be fixed and rerun. Release Drafter does not create the NBGV tag: NBGV creates the tag, and the tag workflow publishes the matching draft.

Use include-pre-releases: false so stable release notes compare against the previous stable release rather than treating previews and RCs as a new baseline.

Release Drafter configuration

The sample keeps release-note classification in .github/release-drafter.yml. Categories group pull requests by labels, exclude-labels omits changes such as skip-changelog, and version-resolver provides a fallback SemVer increment for draft metadata. NBGV remains the source of the artifact version; Release Drafter does not calculate or override the version passed by the workflow.

Source: versioning-samples/.github/release-drafter.yml

versioning-samples/.github/release-drafter.yml
categories:
  - title: 🐛 Bug Fixes
    when:
      labels:
        - bug
        - fix
  - title: 🚀 Features
    when:
      labels:
        - feature
        - feat
 
exclude-labels:
  - skip-changelog
 
version-resolver:
  major:
    labels:
      - breaking-changes
      - major
  minor:
    labels:
      - minor
  patch:
    labels:
      - patch
  default: patch

Autolabeler rules in the same file infer labels from branch names and pull-request titles, such as feat to feature, fix to bug, and docs to documentation. That makes release notes useful without requiring every contributor to apply labels manually.

Showing the effective version at runtime

The Dockerfile accepts version metadata as build arguments and exposes it as environment variables to the published application:

Source: versioning-samples/src/Dockerfile

versioning-samples/src/Dockerfile
ENV APP_SEMVER=${VERSION}
ENV APP_INFORMATIONAL_VERSION=${INFORMATIONAL_VERSION}
ENV APP_NBGV_SEMVER=${NBGV_SEMVER}
ENV APP_ASSEMBLY_VERSION=${ASSEMBLY_VERSION}
ENV APP_DATE_STAMP=${DATE_STAMP}
ENV APP_REVISION=${REVISION}
ENV APP_COMMIT=${COMMIT}
ENV APP_DEPLOYMENT_ENVIRONMENT=${DEPLOYMENT_ENVIRONMENT}

These values are exposed by the sample's /version endpoint:

Source: versioning-samples/src/Program.cs

versioning-samples/src/Program.cs
app.MapGet(
  "/",
  () =>
  {
    var version = versioning_samples.VersionInfoProvider.GetVersionInfo();
 
    return Results.Ok(
      new
      {
        message = "versioning-samples web app",
        versionEndpoint = "/version",
        version = version.SemVer,
        source = version.Source,
      }
    );
  }
);
 
app.MapGet("/version", () => Results.Ok(versioning_samples.VersionInfoProvider.GetVersionInfo()));

versioning-samples/src/VersionInfoProvider.cs uses a clear precedence order: a published version.json first when one is present, APP_* environment variables second, and assembly metadata from NBGV as the local fallback. The current workflow builds with src as the Docker context, so its container uses the APP_* values supplied by the Dockerfile. It passes SemVer, assembly version, informational version, commit, and deployment environment. DATE_STAMP and REVISION are supported by the Dockerfile but remain empty unless a caller supplies them. Local runs fall back to assembly metadata when no CI values exist.

Testing the release policy

The sample tests version behavior in a temporary Git repository instead of relying only on the current checkout. VersioningSandbox copies version.json, the local tool manifest, and release-version.sh, initializes a fresh main branch, restores the tools, and then exercises the same Git and NBGV commands used by the release process.

Two end-to-end tests in VersioningStrategyTests verify the same complete sequence. One uses direct NBGV operations; the other invokes release-version.sh:

initialize preview train -> 1.0.0-preview.0 (validation only)
feature merge -> 1.0.0-preview.1.YYDDD.RUN_NUMBER
feature merge -> 1.0.0-preview.2.YYDDD.RUN_NUMBER
prepare RC train -> v1.0.0-rc.1
merge RC fix -> v1.0.0-rc.2
prepare stable -> 1.0.0.g<commit> (validation only)
tag merged stable commit -> v1.0.0 -> 1.0.0
prepare next train -> 1.1.0-preview.1
merge next feature -> 1.1.0-preview.2

Both tests simulate protected main by preparing changes on short-lived branches and squash-merging them into the sandbox's main branch. The helper-driven test runs prepare-rc, prepare-stable, prepare-train, and tag through Git Bash on Windows. Each test checks NBGV's calculated SemVer separately from the tag at HEAD, so a tag alone cannot satisfy the version assertion. A separate regression test puts a mismatched v9.9.9 tag on a preview commit and verifies NBGV still calculates 1.0.0-preview.1 from version.json and Git height.

Run the complete versioning test project with:

dotnet test ./tests/versioning-samples.Tests/versioning-samples.Tests.csproj --configuration Release

These tests validate version intent and release commands. CI still remains responsible for validating the complete GitHub Actions path, registry permissions, Release Drafter updates, and deployment environment approvals.

Release scenario

main is protected, so feature and version-intent changes reach it through pull requests. The examples assume squash merges because the tests use one commit per accepted branch. Run the local commands from Git Bash on Windows or a normal Bash shell on Linux and macOS.

# Initialize the 1.0.0 preview train. This commit establishes version.json,
# calculates 1.0.0-preview.0, and is validated but not published by CI.
git switch main
git pull --ff-only
git switch -c chore/initialize-1.0.0-preview
dotnet nbgv set-version "1.0.0-preview.{height}"
git add version.json
git commit -m "chore: initialize 1.0.0 preview train"
git switch main
git merge --squash chore/initialize-1.0.0-preview
git commit -m "chore: initialize 1.0.0 preview train"
git branch -d chore/initialize-1.0.0-preview
# Version produced on main: 1.0.0-preview.0 (validation only)
 
# First feature: its squash merge produces 1.0.0-preview.1.
git switch main
git pull --ff-only
git switch -c feature/customer-export
git add -A
git commit -m "feat: add customer export"
git push -u origin feature/customer-export
# Open and squash-merge the pull request. CI publishes preview.1 to dev.
 
# Second feature: its squash merge produces 1.0.0-preview.2.
git switch main
git pull --ff-only
git switch -c feature/add-auth
git add -A
git commit -m "feat: add authentication"
git push -u origin feature/add-auth
# Open and squash-merge the pull request. CI publishes preview.2 to dev.
 
# First RC: prepare rc.{height} on a branch.
git switch main
git pull --ff-only
git switch -c chore/prepare-1.0.0-rc
dotnet nbgv set-version "1.0.0-rc.{height}"
# Helper equivalent: ./release-version.sh prepare-rc 1.0.0
git add version.json
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 pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
# Version produced on main: 1.0.0-rc.1.g<commit> (validation only)
dotnet nbgv tag
# Helper equivalent: ./release-version.sh tag
git push origin v1.0.0-rc.1
 
# RC fix: no version-file edit is needed; merge height produces rc.2.
git switch main
git pull --ff-only
git switch -c fix/release-candidate
git add -A
git commit -m "fix: correct release candidate behavior"
git push -u origin fix/release-candidate
# Open and squash-merge the pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
# Version produced on main: 1.0.0-rc.2.g<commit> (validation only)
dotnet nbgv tag
git push origin v1.0.0-rc.2
 
# Stable: prepare stable intent on a branch.
git switch main
git pull --ff-only
git switch -c chore/prepare-1.0.0
dotnet nbgv set-version 1.0.0
# Helper equivalent: ./release-version.sh prepare-stable 1.0.0
git add version.json
git commit -m "chore: prepare 1.0.0"
git push -u origin chore/prepare-1.0.0
# Open and squash-merge the pull request, then update local main.
git switch main
git pull --ff-only
version="$(dotnet nbgv get-version -v SemVer2)"
# Version produced on main: 1.0.0.g<commit> (validation only)
# Verify the offset properties were removed, then tag this merged main commit.
dotnet nbgv tag
git push origin v1.0.0
 
# Start the 1.1.0 preview train on a branch.
git switch main
git pull --ff-only
git switch -c chore/prepare-1.1.0-preview
dotnet nbgv set-version "1.1.0-preview.{height}"
# Helper equivalent: ./release-version.sh prepare-train 1.1.0
git add version.json
git commit -m "chore: start 1.1.0 preview train"
git push -u origin chore/prepare-1.1.0-preview
# Open and squash-merge the pull request. Its version is 1.1.0-preview.1.
 
# First feature in the new train produces 1.1.0-preview.2.
git switch main
git pull --ff-only
git switch -c feature/add-authorization
git add -A
git commit -m "feat: add authorization"
git push -u origin feature/add-authorization
# Open and squash-merge the pull request.

The prepare-train, prepare-rc, and prepare-stable commands in release-version.sh update version.json; they do not commit or push. Review and commit those changes before creating a release tag. The tag command runs dotnet nbgv tag for the already-committed version and leaves the final git push under your control.

Do not edit preview.1 to make preview.2, or rc.1 to make rc.2. NBGV calculates both counters from Git height. Change version intent only when moving between preview, RC, stable, or a new base release train.

Release decision points

The workflow has two different kinds of commits: ordinary development commits and release-intent commits. Keeping that distinction clear makes the process easier to review.

Ordinary development

Feature and fix branches do not edit version.json. After a squash merge into main, NBGV calculates the next preview from Git height. The merge itself is the version event:

feature merge -> 1.0.0-preview.1
feature merge -> 1.0.0-preview.2

No one should change preview.1 to preview.2 by hand. The height is the counter, and the resulting preview remains an untagged development artifact.

Entering the RC train

The first RC is a deliberate version-intent change. A release-preparation branch changes version.json to 1.0.0-rc.{height} and merges into protected main. That merge calculates 1.0.0-rc.1. Later RC fixes are ordinary fix branches. Since the RC template remains committed, each merged fix advances the height to the next RC without another version-file change.

Promoting to stable

Stable is another deliberate version-intent change. A preparation branch changes version.json to 1.0.0, then merges into main. CI validates that commit as 1.0.0.g<commit> but does not publish it. The v1.0.0 tag selects the approved stable commit, calculates clean 1.0.0, and starts production publication.

Creating a release tag

Create tags only from the merged commit on main, after validation has passed. dotnet nbgv tag derives the tag from the committed version, while CI checks that the pushed tag matches NBGV's calculated version for that commit. This keeps a typo such as v1.0.0-rc.3 from publishing an artifact calculated as 1.0.0-rc.2.

Should a tag change version.json?

There are two workable policies, but they produce different answers from NBGV:

  1. Tag-controlled releases: create v1.0.0-rc.1 or v1.0.0 manually and configure CI to treat the tag name as the published version. NBGV still calculates from version.json and Git height; publicReleaseRefSpec does not make nbgv get-version adopt the tag's version. CI must explicitly override NBGV's calculated version for artifact and runtime metadata, so this is not the policy used by the sample.
  2. NBGV-controlled releases: change the committed version intent first, merge that change through protected main, and run dotnet nbgv tag on the approved commit. The tag is then derived from the same version that local builds and CI calculate.

This sample recommends the second policy. Change version.json when entering the RC train, from 1.0.0-preview.{height} to 1.0.0-rc.{height}, and when moving to stable, from the RC template to 1.0.0. Do not change it for later RC fixes: those commits inherit 1.0.0-rc.{height}, so Git height produces rc.2, rc.3, and so on. In every case, create the final tag with nbgv tag and push the tag only after the merged commit passes validation. The matching tag marks that calculated version as a release; it does not set the version.

Release Drafter runs inside the same publish workflow. It updates the preview draft for published preview.1+ builds and publishes release notes for RC and stable tags. The preview.0 train-start commit is validation-only and does not enter the draft. There is no separate release-notes workflow to coordinate, and a failed image publication still fails the job even though the Drafter step uses always() to preserve the draft update.

  • Protect main with pull-request rules.
  • Keep version intent in reviewed versioning-samples/version.json changes.
  • Use NBGV SemVer2 everywhere.
  • Publish commit-height previews from main and keep GitHub releases as drafts.
  • Publish RC and stable releases only from matching tags.
  • Use Release Drafter as the single release-note and release-publication action.
  • Require production environment approval.
  • Keep the release helper deterministic and explicit.

This model makes development releases frequent without making release intent ambiguous. Staging and production have a clear promotion boundary: the approved version is committed first, and the exact commit is tagged only when ready.

Conclusion

Commit-height previews let ordinary development move without version-file edits, while NBGV keeps local builds, CI, containers, and release tags tied to the same version intent. RC and stable releases use a different discipline: update and commit version.json, validate that exact commit, then run nbgv tag. Keep the version file, helper, workflow, container metadata, and runtime endpoint aligned, and treat every tag as approval of one exact commit.