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

Mehdi Hadeli
@mehdihadeli
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
- Why versioning strategy matters
- The design principle: one source of truth
- Why Nerdbank.GitVersioning fits this model
version.jsonexplained- Environment strategy: dev, staging, production
- SemVer patterns used by the sample
- Why preview builds use a Microsoft-style format
- How the CI/CD pipeline works
- How local and dispatch release flows work
- Optional and mandatory gates
- The release helper script explained
- The dispatch workflows explained
- The verification script explained
- Examples of complete release flows
- Common pitfalls and how this strategy avoids them
- Why this strategy is practical
- Recommended operating model
- 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.3mean in development? - What makes a build become
1.0.0-rc.6? - Is
1.0.0merely 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 saysrc - 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.jsonandNerdbank.GitVersioningdefine 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.jsondeclares the intended release linedotnet nbgv get-versioncomputes 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:
- canonical version identity
- artifact-specific enrichment for preview builds
- 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.61.0.01.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:
mainusually stays onX.Y.Z-preview.{height}- merges to
mainadvance the preview line - CI always validates
main - CD deploys to
devonly 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.jsonmust already declare an RC version onmain- a tag like
v1.0.0-rc.6must 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.jsonmust already declare a stable version onmain- a tag like
v1.0.0must 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 identitypreview.3= NBGV preview train / height concept26138= date stamp (yyDDD)7= revision / commit count used in CI output35f10a7= 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.10810.0.0-rc.1.25451.10710.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:
cicd
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.6andv1.0.0
ci job responsibilities
The ci job does several things:
- checks out the repo with full history
- restores the .NET SDK and local tools
- restores project dependencies
- calculates version information
- builds the app
- tests the app
- publishes artifacts
- writes
publish/version.json - validates Docker build
Version calculation logic
This is the most important part of the workflow.
The workflow computes:
BASE_SEMVERfromdotnet nbgv get-version -v SemVer2ASSEMBLY_VERSIONDATE_STAMPREVISIONCOMMIT
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
SEMVERremains 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
SEMVERremains the base NBGV version
For main pushes
If the ref is just a branch push to main:
- environment becomes
dev - effective
SEMVERbecomes 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:
nbgvSemVersemVerinformationalVersionassemblyVersiondateStamprevisioncommitenvironment
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
mainstill 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.
- Use a branch to change
version.json - Open a PR to
main - Merge the PR
- Pull latest
main - 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.
- Use a branch to change
version.json - Open a PR to
main - Merge the PR
- Run a
workflow_dispatchworkflow to create the tag from GitHub - 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
maindo 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.jsontoX.Y.Z-preview.{height} - commits the change
- validates local SemVer with
nbgv - prints PR instructions
What prepare-staging does
- updates
version.jsontoX.Y.Z-rc.N - commits the change
- validates local SemVer with
nbgv - prints PR instructions
What prepare-production does
- updates
version.jsontoX.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(defaultmain)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(defaultmain)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
version.jsonis on1.1.0-preview.{height}- Features and fixes merge to
main - CI computes base preview with
nbgv - CI enriches preview for dev artifacts
- 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)
- Run
prepare-staging - Merge PR to
main - Run
Staging Release Dispatch - Workflow creates
v1.0.0-rc.6 ci-cd.ymldeploys 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)
- Run
prepare-production - Merge PR to
main - Run
Production Release Dispatch - Workflow creates
v1.0.0 ci-cd.ymlstarts production deployment- 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:
- clarity
- safety
- 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.
Recommended operating model
A practical default model for many teams would be:
- protect
mainwith PR rules - keep
version.jsonauthoritative - 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.


