18 min readMehdi Hadeli

Designing a Practical Versioning Strategy for Development, Staging, and Production with SemVer and Nerdbank.GitVersioning

On this page

Table of contents

Repository reference: https://github.com/mehdihadeli/versioning-samples

Introduction

Versioning is easy when it is just a number in a package or an assembly. It becomes much harder when that number starts carrying real operational meaning.

The moment a team introduces multiple environments such as development, staging, and production, versioning stops being a cosmetic concern. A version begins to answer serious questions:

  • What exactly is deployed in this environment?
  • Is this build just a development preview or a release candidate?
  • Which commit produced this artifact?
  • Should this deployment be automatic or should it require explicit approval?
  • Does local development see the same version that CI/CD sees?
  • Is the version derived from Git history, from a tag, from CI variables, or from a mix of all three?

If those questions do not have consistent answers, teams gradually lose trust in their own release process. Local builds say one thing, CI says another, Docker tags say something else, and environment promotions become harder to reason about.

This article explains a practical versioning strategy based on the sample repository versioning-samples. The strategy uses:

  • Semantic Versioning (SemVer)
  • GitHub Flow
  • Nerdbank.GitVersioning (nbgv)
  • GitHub Actions
  • environment-aware release behavior
  • optional and mandatory release gates

The goal is not to build the most complicated release system possible. The goal is to build one that is predictable, auditable, traceable, and still practical for day-to-day engineering work.


Table of contents

  1. Why versioning strategy matters
  2. The design principle: one source of truth
  3. Why Nerdbank.GitVersioning fits this model
  4. version.json explained
  5. Environment strategy: dev, staging, production
  6. SemVer patterns used by the sample
  7. Why preview builds use a Microsoft-style format
  8. How the CI/CD pipeline works
  9. How local and dispatch release flows work
  10. Optional and mandatory gates
  11. The release helper script explained
  12. The dispatch workflows explained
  13. The verification script explained
  14. Examples of complete release flows
  15. Common pitfalls and how this strategy avoids them
  16. Why this strategy is practical
  17. Recommended operating model
  18. Conclusion

Why versioning strategy matters

Many teams say they use SemVer, but what they often mean is only that their versions look like SemVer. That is not enough.

A useful versioning strategy should define not only the version format, but also the operational meaning of that format.

For example:

  • What does 1.0.0-preview.3 mean in development?
  • What makes a build become 1.0.0-rc.6?
  • Is 1.0.0 merely a tag name, or is it also the canonical version in source control?
  • When a build reaches staging, what guarantees that it is actually the intended release candidate?
  • Should production deployments always require approval, even when the version itself is correct?

Without a versioning strategy, teams slowly accumulate release ambiguity:

  • local builds may say preview, while CI says rc
  • tags may be pushed from stale checkouts
  • development may accidentally receive RC or stable builds
  • production may be released without enough verification or authorization

The sample repository solves these problems by combining a clear version source of truth with environment-specific promotion rules.


The design principle: one source of truth

The central principle in this sample is simple:

version.json and Nerdbank.GitVersioning define the canonical version state.

That means the version is not invented by CI, not improvised from branch names, and not reinterpreted differently per environment.

Instead:

  • version.json declares the intended release line
  • dotnet nbgv get-version computes the base semantic version from repository state
  • CI/CD validates that promotion tags match the computed NBGV version
  • only development builds get an enriched, CI-specific preview version for traceability

This creates a healthy separation between:

  1. canonical version identity
  2. artifact-specific enrichment for preview builds
  3. environment-specific promotion rules

That separation is the reason the system remains understandable.


Why Nerdbank.GitVersioning fits this model

Nerdbank.GitVersioning is a strong fit for .NET teams that want deterministic versioning directly from source control.

It works well here because it gives the repository a version engine that is:

  • reproducible locally and in CI
  • controlled by checked-in configuration
  • integrated with MSBuild and assembly metadata
  • capable of producing SemVer, assembly version, and informational version
  • easy to query in scripts and workflows

A developer can run:

dotnet nbgv get-version -v SemVer2

CI can run the same command.

That simple property is incredibly valuable because it keeps local reasoning and CI reasoning aligned.

When a release strategy uses one engine locally and a different inference mechanism in CI, teams eventually hit mismatches. The sample deliberately avoids that for release promotion:

  • RC/stable tags are valid only when they match nbgv
  • CI does not reinterpret the release line at the moment of promotion

That is a strong operational simplification.


version.json explained

The repository’s version.json is the heart of the versioning strategy.

At the moment of writing, it contains:

{
  "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
  "version": "1.0.0-rc.6",
  "assemblyVersion": {
    "precision": "revision"
  },
  "versionHeightOffset": 0,
  "publicReleaseRefSpec": [
    "^refs/heads/main$",
    "^refs/tags/v\\d+\\.\\d+\\.\\d+-rc\\.\\d+$",
    "^refs/tags/v\\d+\\.\\d+\\.\\d+$"
  ],
  "cloudBuild": {
    "setVersionVariables": true,
    "setAllVariables": true,
    "buildNumber": {
      "enabled": true,
      "includeCommitId": {
        "when": "always",
        "where": "buildMetadata"
      }
    }
  },
  "release": {
    "tagName": "v{version}",
    "firstUnstableTag": "preview"
  }
}

Let’s break down the important pieces.

version

This is the declared version line.

Examples during the lifecycle might be:

  • 1.0.0-preview.{height}
  • 1.0.0-rc.6
  • 1.0.0
  • 1.1.0-preview.{height}

This is the most important setting because it tells NBGV what release line the repository is currently on.

assemblyVersion.precision

This controls assembly version precision. In this sample, revision precision is used so assembly versions remain useful without changing the higher-level SemVer semantics.

publicReleaseRefSpec

This is a critical setting in the sample.

It defines which refs should be treated as public release refs:

  • main
  • RC tags (vX.Y.Z-rc.N)
  • stable tags (vX.Y.Z)

This matters because it affects how NBGV produces public-facing versions versus versions that may include additional Git metadata.

cloudBuild

This section tells NBGV to set version variables for cloud builds. It also enables build number handling and ensures the commit ID is included as build metadata.

This aligns well with the sample’s preview build strategy, where traceability matters.

release.tagName

This says release tags should look like:

  • v{version}

So if the version is 1.0.0-rc.6, then the tag becomes:

  • v1.0.0-rc.6

And if the version is 1.0.0, then the tag becomes:

  • v1.0.0

release.firstUnstableTag

This says the default prerelease train is preview, which fits the dev-environment policy used in this repo.


Environment strategy: dev, staging, production

The sample assigns a very clear meaning to each environment.

Development (dev)

Development is where the next planned release line is exercised continuously.

The rules are:

  • main usually stays on X.Y.Z-preview.{height}
  • merges to main advance the preview line
  • CI always validates main
  • CD deploys to dev only when the version is a preview build

This is important because it prevents RC or stable release lines from polluting the dev environment.

Staging (staging)

Staging is for release candidates only.

The rules are:

  • version.json must already declare an RC version on main
  • a tag like v1.0.0-rc.6 must be created
  • the tag must match the NBGV-computed version
  • staging deployment only happens through that RC tag path

This makes staging a controlled promotion environment instead of just “whatever happens after a merge.”

Production (production)

Production is for stable releases only.

The rules are:

  • version.json must already declare a stable version on main
  • a tag like v1.0.0 must be created
  • the tag must match the NBGV-computed version
  • production deployment only happens through the stable tag path
  • production should additionally use a mandatory approval gate through GitHub Environments

That creates a very clean production release story.


SemVer patterns used by the sample

The sample uses multiple related SemVer shapes depending on context.

1. Base preview version

This is what local NBGV typically returns during normal development:

1.0.0-preview.3

This is the canonical preview line.

2. Effective CI preview version

For development artifacts and Docker image tags, CI enriches the preview version to make it more traceable:

1.0.0-preview.3.26138.7+35f10a7

Where:

  • 1.0.0 = base release identity
  • preview.3 = NBGV preview train / height concept
  • 26138 = date stamp (yyDDD)
  • 7 = revision / commit count used in CI output
  • 35f10a7 = short Git SHA

3. Docker-safe preview image tag

Because Docker tags cannot contain +, the sample converts build metadata to a Docker-safe form:

1.0.0-preview.3.26138.7-35f10a7

4. RC version

1.0.0-rc.6

5. RC tag

v1.0.0-rc.6

6. Stable version

1.0.0

7. Stable tag

v1.0.0

This mixture of patterns is intentional and useful.


Why preview builds use a Microsoft-style format

The sample’s preview version pattern is intentionally similar to Microsoft package versioning patterns used by projects like EF Core and ASP.NET Core.

Examples from Microsoft-style prerelease packages often look like this:

  • 10.0.0-preview.7.25380.108
  • 10.0.0-rc.1.25451.107
  • 10.0.0-rc.2.25502.107

These version shapes are useful because they preserve both:

  • release identity (10.0.0-preview.7)
  • build traceability (25380.108)

The sample follows a similar idea for dev builds, but adds an extra short SHA:

1.0.0-preview.3.26138.7+35f10a7

That extra Git SHA is especially useful when:

  • diagnosing build provenance
  • correlating Docker images with source control
  • browsing CI artifacts
  • debugging environment drift

So the sample intentionally uses a hybrid strategy:

  • clean base NBGV version for canonical semantics
  • richer effective preview version for CI artifacts

This gives you the best of both worlds.


How the CI/CD pipeline works

The main workflow is .github/workflows/ci-cd.yml.

At a high level, it has two jobs:

  • ci
  • cd

Trigger rules

The workflow runs on:

on:
  push:
    branches: [main]
    tags: ['v*']

So it reacts to:

  • pushes to main
  • pushes of release tags like v1.0.0-rc.6 and v1.0.0

ci job responsibilities

The ci job does several things:

  1. checks out the repo with full history
  2. restores the .NET SDK and local tools
  3. restores project dependencies
  4. calculates version information
  5. builds the app
  6. tests the app
  7. publishes artifacts
  8. writes publish/version.json
  9. validates Docker build

Version calculation logic

This is the most important part of the workflow.

The workflow computes:

  • BASE_SEMVER from dotnet nbgv get-version -v SemVer2
  • ASSEMBLY_VERSION
  • DATE_STAMP
  • REVISION
  • COMMIT

Then it applies environment-specific rules.

For production tags

If the ref matches refs/tags/vX.Y.Z:

  • it validates the tag equals v${BASE_SEMVER}
  • environment becomes production
  • effective SEMVER remains the base NBGV version

For RC tags

If the ref matches refs/tags/vX.Y.Z-rc.N:

  • it validates the tag equals v${BASE_SEMVER}
  • environment becomes staging
  • effective SEMVER remains the base NBGV version

For main pushes

If the ref is just a branch push to main:

  • environment becomes dev
  • effective SEMVER becomes the enriched preview version:
${BASE_SEMVER}.${DATE_STAMP}.${REVISION}+${COMMIT}

That is how the sample gets its Microsoft-style preview artifact versioning.

Informational version

The workflow then computes:

  • INFORMATIONAL_VERSION

If SEMVER already includes +, it uses it directly. Otherwise it appends +${COMMIT}.

This preserves rich traceability in assembly and runtime metadata.

Docker tag

Because Docker cannot use +, the workflow converts:

DOCKER_TAG="${SEMVER/+/-}"

So build metadata becomes Docker-safe.

Publish artifact metadata

The workflow writes a publish/version.json file containing:

  • nbgvSemVer
  • semVer
  • informationalVersion
  • assemblyVersion
  • dateStamp
  • revision
  • commit
  • environment

This file is important because the runtime app can read it and report the effective CI/CD-selected version later.

cd job responsibilities

The cd job:

  • logs into GHCR
  • generates Docker metadata
  • builds and pushes the Docker image
  • prints deployment info
  • targets the appropriate GitHub environment

Dev deploy gate

A key refinement exists in the cd job:

if: ${{ needs.ci.outputs.environment != 'dev' || contains(needs.ci.outputs.semver, '-preview.') }}

This means:

  • dev deployment happens only for preview versions
  • RC/stable versions on main still go through CI validation
  • but they do not deploy to dev

That keeps environment semantics clean.


How local and dispatch release flows work

The sample supports two release styles.

Simple local flow

This is the low-ceremony option.

  1. Use a branch to change version.json
  2. Open a PR to main
  3. Merge the PR
  4. Pull latest main
  5. Create and push the release tag locally

This works well for smaller teams and open-source style workflows.

Controlled dispatch flow

This is the more governed option.

  1. Use a branch to change version.json
  2. Open a PR to main
  3. Merge the PR
  4. Run a workflow_dispatch workflow to create the tag from GitHub
  5. Let the tag-triggered CI/CD workflow handle deployment

This is useful when teams want stronger auditability or less dependency on a local workstation.


Optional and mandatory gates

A major strength of the sample is that it distinguishes between gates that are always advisable and gates that are optional depending on team maturity.

Mandatory gates

1. Pull request gate on main

This is enforced through branch rules. It ensures:

  • version changes are reviewed
  • direct pushes to main do not redefine release state
  • release intent remains visible in history

2. Tag/version consistency gate

The CI workflow validates that RC and stable tags match NBGV.

This prevents:

  • tagging the wrong commit
  • promoting the wrong semantic version
  • silently overriding repository-defined release state

3. Production approval gate

Production should use GitHub Environment required reviewers.

This is different from merely creating a stable tag. The tag expresses release identity; the approval expresses deployment authorization.

Optional gates

1. Staging dispatch workflow

The staging-release-dispatch.yml workflow is optional. It is useful when you want server-side tag creation and a more controlled release trigger.

2. Production dispatch workflow

The production-release-dispatch.yml workflow is optional, but often more attractive than local tagging because production is riskier than staging.

3. Tag rulesets

If a team wants to restrict who can create release tags, a GitHub tag ruleset for v* can be added.

4. Staging approval gate

Some teams may want staging approvals too, but many teams keep staging faster and more self-service.


The release helper script explained

The repository includes release-version.sh as a helper for version transitions.

This script does not replace versioning logic. Instead, it makes the workflow easier and more consistent.

Supported commands

./release-version.sh prepare-dev <major.minor.patch>
./release-version.sh prepare-staging <major.minor.patch> <rc-number>
./release-version.sh prepare-production <major.minor.patch>
./release-version.sh tag [--push]

What prepare-dev does

  • updates version.json to X.Y.Z-preview.{height}
  • commits the change
  • validates local SemVer with nbgv
  • prints PR instructions

What prepare-staging does

  • updates version.json to X.Y.Z-rc.N
  • commits the change
  • validates local SemVer with nbgv
  • prints PR instructions

What prepare-production does

  • updates version.json to X.Y.Z
  • commits the change
  • validates local SemVer with nbgv
  • prints PR instructions

What tag --push does

  • requires you to be on main
  • computes the current SemVer from NBGV
  • creates a tag using dotnet nbgv tag
  • optionally pushes it

This script is a good example of lightweight automation that improves consistency without trying to become a release platform on its own.


The dispatch workflows explained

The sample includes two dispatch workflows.

staging-release-dispatch.yml

This workflow allows a release operator to create an RC tag from GitHub.

Inputs:

  • target_ref (default main)
  • expected_version (optional)

Validation steps:

  • confirm selected ref is on origin/main
  • compute SemVer using nbgv
  • ensure it is an RC version
  • optionally ensure it matches the expected version
  • ensure tag does not already exist

Action:

  • create tag with dotnet nbgv tag
  • push the tag

Effect:

  • tag push triggers ci-cd.yml
  • deployment proceeds to staging

production-release-dispatch.yml

This workflow is similar, but stricter.

Inputs:

  • target_ref (default main)
  • expected_version (required)

Validation steps:

  • confirm selected ref is on origin/main
  • compute SemVer using nbgv
  • ensure it is stable (no prerelease suffix)
  • ensure it matches expected stable version
  • ensure tag does not already exist

Action:

  • create tag with dotnet nbgv tag
  • push the tag

Effect:

  • tag push triggers ci-cd.yml
  • deployment proceeds toward production
  • production environment approval can then act as the mandatory final gate

These dispatch workflows provide a very good compromise between automation and control.


The verification script explained

The repository also includes nerdbank-versioning.sh.

This script is not a release tool. It is a verification harness.

It creates a disposable sandbox repository and simulates versioning scenarios so the team can validate the intended release strategy.

It helps test questions like:

  • what should preview builds look like?
  • how does preview numbering change after squash merges?
  • what effective dev version should CI produce?
  • how should RC and stable promotions behave?
  • what does the next release train look like after production release?

This kind of script is extremely valuable because versioning strategy is easy to discuss abstractly but harder to validate consistently.

A verification harness makes the strategy executable.


Examples of complete release flows

Development preview flow

  1. version.json is on 1.1.0-preview.{height}
  2. Features and fixes merge to main
  3. CI computes base preview with nbgv
  4. CI enriches preview for dev artifacts
  5. Dev deploys automatically

Staging RC flow (simple local mode)

./release-version.sh prepare-staging 1.0.0 6
# push branch, open PR, merge PR

git checkout main
git pull --ff-only origin main
./release-version.sh tag --push

Staging RC flow (controlled dispatch mode)

  1. Run prepare-staging
  2. Merge PR to main
  3. Run Staging Release Dispatch
  4. Workflow creates v1.0.0-rc.6
  5. ci-cd.yml deploys to staging

Production flow (simple local mode)

./release-version.sh prepare-production 1.0.0
# push branch, open PR, merge PR

git checkout main
git pull --ff-only origin main
./release-version.sh tag --push

Production flow (controlled dispatch mode)

  1. Run prepare-production
  2. Merge PR to main
  3. Run Production Release Dispatch
  4. Workflow creates v1.0.0
  5. ci-cd.yml starts production deployment
  6. production environment approval is required

Common pitfalls and how this strategy avoids them

Pitfall 1: local and CI disagree on RC/stable

Avoided by strict tag/version validation.

Pitfall 2: dev receives RC/stable builds

Avoided by the dev deployment gate in ci-cd.yml.

Pitfall 3: wrong commit gets tagged

Avoided by tagging merged main or using dispatch workflows that validate origin/main ancestry.

Pitfall 4: production deploy happens without human authorization

Avoided by requiring production environment reviewers.

Pitfall 5: version meaning becomes unclear

Avoided by keeping version.json authoritative and using NBGV consistently.


Why this strategy is practical

The strategy is practical because it balances three things that often fight each other:

  1. clarity
  2. safety
  3. low operational friction

It does not require long-lived release branches. It does not invent versions in CI. It does not rely entirely on human memory. It does not over-engineer development releases.

Instead, it gives each environment a clear role:

  • preview for dev
  • RC for staging
  • stable for production

And it gives teams two valid release paths:

  • simple local mode
  • controlled dispatch mode

That flexibility is a major advantage.


A practical default model for many teams would be:

  • protect main with PR rules
  • keep version.json authoritative
  • use NBGV everywhere
  • let dev deploy only preview builds
  • promote staging only with RC tags
  • promote production only with stable tags
  • require production environment approval
  • use dispatch workflows when stronger governance is needed
  • keep local tagging available as a simpler fallback

That is a very strong, real-world balance.


Conclusion

The best thing about this sample’s versioning strategy is not that it uses a particular tool. It is that it makes version numbers operationally meaningful.

With this strategy:

  • development versions communicate active release-line progress
  • staging versions communicate release-candidate intent
  • production versions communicate final release identity
  • local and CI share a canonical version engine
  • preview artifacts gain traceability without losing semantic meaning
  • release promotions are validated instead of improvised
  • approvals are applied where they matter most

That is what good versioning strategy should do.

It should reduce ambiguity, not increase it. It should make releases boring, repeatable, and trustworthy. And for modern .NET teams using GitHub Flow, Nerdbank.GitVersioning is a very strong foundation for achieving exactly that.