Mert Özen Further With Every Line
DevOps Versioning and Release Management — #01

What Does a Version Number Say? SemVer 2.0.0, Pre-release, Build Metadata and CalVer

Mert Özen Nov 14, 2026 13 min 2 views
What Does a Version Number Say? SemVer 2.0.0, Pre-release, Build Metadata and CalVer

Choose a version scheme by whom it makes a promise to: for packages SemVer 2.0.0 promises compatibility, for deployed applications CalVer plus a build number answers "what shipped, and when". We compare pre-release and build metadata ordering in NuGet.Versioning and npm semver with real output, and cover 0.x ranges, kinds of breaking changes, the .NET and Angular support policies and a decision table for Bookstore.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), NuGet.Versioning 8.0.0, Node.js 24 and semver 7.8.6.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, we decided to move the DTOs that the API and the Angular client share into a NuGet package called Bookstore.Contracts. On the way to the package's first release, these versions have piled up in the feed:

1.0.0-rc.1
1.0.0-beta.11
1.0.0
1.0.0-alpha.beta
1.0.0-beta.2
0.9.12
1.0.0-alpha
1.0.0-beta
1.0.0-alpha.1

Sort them from oldest to newest the way NuGet and the SemVer 2.0.0 specification see them. Answer two questions in particular: is beta.11 newer, or beta.2? Where does alpha.beta land? We will see the answer in the output of the program below; in the meantime, let's look at what a version number actually says.

SemVer 2.0.0: Three Numbers, One Promise

SemVer itself is not a tool, it is a contract. The first item of the specification asks you to declare a public API; every other rule is defined against that API. Each of the three numbers in MAJOR.MINOR.PATCH tells the person using the package something different:

  • PATCH went up: the API did not change, there are only backward-compatible bug fixes. You can upgrade without looking.
  • MINOR went up: backward-compatible functionality was added. Your existing code compiles and behaves the same; use the new parts if you want them.
  • MAJOR went up: there is a backward-incompatible change. Do not upgrade without reading the release notes.

There is one more rule that people often miss: the contents of a released version are never modified. Once 1.4.2 is out, even if it has a bug, the fix ships as 1.4.3. NuGet and npm enforce this at the registry level; in the NuGet post of this series we will try to push the same version twice and look at the error.

How the numbers are compared matters too: each part is compared as a number, not as text. 1.10.0 is greater than 1.9.0. In the "Git Tags and Releases" post of the Git & GitHub series we saw that git tag lists tags alphabetically and that --sort=-v:refname fixes it; the same mistake shows up in your own scripts, and any code that sorts versions as strings will eventually get it wrong.

Pre-release and Build Metadata

Two optional parts can follow the three numbers. The part that starts with a hyphen is the pre-release label (1.0.0-beta.2), the part that starts with a plus is build metadata (1.0.0+sha.5114f85). Both consist of dot-separated identifiers; identifiers may contain only ASCII letters, digits and hyphens, they cannot be empty, and numeric identifiers in a pre-release cannot have leading zeros (beta.01 is invalid).

The ordering rules work like this: first the three numbers are compared. If they are equal, the version with a pre-release is lower than the one without; so 1.0.0-rc.1 comes before 1.0.0. When two pre-releases are compared, identifiers are taken one by one from left to right: if both are numbers they are compared numerically, otherwise in ASCII order; a numeric identifier is always lower than an alphanumeric one; and if the shared part is equal, the one with more identifiers is greater. Build metadata does not take part in ordering at all.

Let's write a small console app that sorts the list from the guess with the NuGet.Versioning package. This package is the NuGet client's own version logic, so it shows exactly the decision dotnet restore would make:

using NuGet.Versioning;

string[] input =
[
    "1.0.0", "1.0.0-rc.1", "1.0.0-beta.11", "1.0.0-beta.2",
    "1.0.0-alpha.beta", "1.0.0-alpha", "1.0.0-alpha.1", "1.0.0-beta",
    "0.9.12"
];

var sorted = input.Select(NuGetVersion.Parse).Order(VersionComparer.VersionRelease);
Console.WriteLine(string.Join(" < ", sorted.Select(v => v.ToFullString())));

var a = NuGetVersion.Parse("1.0.0+sha.5114f85");
var b = NuGetVersion.Parse("1.0.0+sha.0c2d9e1");
Console.WriteLine($"build metadata ignored: {a == b}");
Console.WriteLine($"with metadata compared: {VersionComparer.VersionReleaseMetadata.Equals(a, b)}");

var upper = NuGetVersion.Parse("1.0.0-RC.1");
var lower = NuGetVersion.Parse("1.0.0-rc.1");
Console.WriteLine($"RC.1 vs rc.1 (NuGet): {upper.CompareTo(lower)}");

foreach (var text in new[] { "1.0.0-beta.01", "1.0", "2026.04.1", "v1.2.3" })
{
    Console.WriteLine($"{text,-14} SemVer: {SemanticVersion.TryParse(text, out _),-5} NuGet: {(NuGetVersion.TryParse(text, out var v) ? v.ToNormalizedString() : "invalid")}");
}
$ dotnet run --project tools/SemverLab
0.9.12 < 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0
build metadata ignored: True
with metadata compared: False
RC.1 vs rc.1 (NuGet): 0
1.0.0-beta.01  SemVer: False NuGet: invalid
1.0            SemVer: False NuGet: 1.0.0
2026.04.1      SemVer: False NuGet: 2026.4.1
v1.2.3         SemVer: False NuGet: invalid

The first line answers the guess. beta.11 is greater than beta.2 because the second identifiers are numbers and 11 > 2. alpha.beta is greater than alpha.1 because an alphanumeric identifier counts as greater than a numeric one. alpha is lower than alpha.1: the shared part is the same and alpha.1 has one more identifier. This order matches the example chain in item 11 of the SemVer specification.

The remaining lines are the interesting part. Two versions that differ only in build metadata are equal for NuGet; if you want to compare metadata as well you need a separate comparer such as VersionComparer.VersionReleaseMetadata. NuGet's version reference also drops metadata when it normalizes versions. That makes it safe to put the commit hash into build metadata: it does not disturb ordering, but it also does not mean you can publish two different packages with the same numbers, because the feed treats them as the same version.

RC.1 and rc.1 coming out equal in NuGet is a deviation from the specification. SemVer asks for ASCII order, where upper case comes before lower case. The NuGet documentation lists this difference explicitly: NuGetVersion compares pre-release labels case-insensitively, accepts a four-part version (1.0.0.1) and only requires MAJOR. Let's ask the same questions of the semver package that npm uses:

import semver from "semver";

console.log("RC.1 vs rc.1 (npm):", semver.compare("1.0.0-RC.1", "1.0.0-rc.1"));
console.log("valid 2026.04.1:", semver.valid("2026.04.1"));
console.log("valid v1.2.3:", semver.valid("v1.2.3"));

const checks = [
  ["1.9.0", "^1.2.3"],
  ["2.0.0", "^1.2.3"],
  ["0.2.9", "^0.2.3"],
  ["0.3.0", "^0.2.3"],
  ["0.0.4", "^0.0.3"],
  ["1.3.0-beta.1", "^1.2.0"],
  ["1.2.4-beta.1", "^1.2.4-alpha.1"],
];
for (const [version, range] of checks) {
  console.log(`${version.padEnd(13)} ${range.padEnd(15)} ${semver.satisfies(version, range)}`);
}
$ cd tools/semver-js
$ npm start

> start
> node index.js

RC.1 vs rc.1 (npm): -1
valid 2026.04.1: null
valid v1.2.3: 1.2.3
1.9.0         ^1.2.3          true
2.0.0         ^1.2.3          false
0.2.9         ^0.2.3          true
0.3.0         ^0.2.3          false
0.0.4         ^0.0.3          false
1.3.0-beta.1  ^1.2.0          false
1.2.4-beta.1  ^1.2.4-alpha.1  true

On the npm side 1.0.0-RC.1 is lower than 1.0.0-rc.1, as the specification says. In a project where the same team publishes both NuGet and npm packages, always writing labels in lower case removes this difference completely. The two ecosystems also part ways on a leading v: npm's semver.valid cleans up v1.2.3 and accepts it, NuGet rejects it. Using v in Git tags is a well-established habit, but it should not leak into the package version; the tools in the third post of this series strip that prefix themselves when they derive a version from a tag.

0.x: Initial Development and Packages That Never Reach 1.0

Item four of the specification defines the 0.y.z range as initial development: anything can change at any time and the public API should not be considered stable. 1.0.0 is the version that defines the public API. Simple on paper, but tools interpret 0.x differently. In the output above, the ^1.2.3 range accepts 1.9.0 and rejects 2.0.0; that is expected. ^0.2.3, however, accepts only 0.2.x and rejects 0.3.0. ^0.0.3 is even narrower: only 0.0.3. In the 0.x world npm treats the first non-zero number from the left like MAJOR; in other words it counts the move from 0.2 to 0.3 as a breaking change.

NuGet has no such special case. Version="0.2.3" in a PackageReference means "at least 0.2.3", and NuGet picks the lowest version that satisfies it. When the user upgrades to 0.3.0 themselves, they get no guarantee, because breaking changes are allowed in 0.x.

The practical outcome: keeping a package on 0.x for a long time tells users "any update may break you". The day the API and the client both start using Bookstore.Contracts, it effectively has a public API; going to 1.0.0 that day is the honest move. The last line of the output shows a separate rule for pre-releases: npm ranges do not accept pre-release versions unless the range itself names a pre-release with the same MAJOR.MINOR.PATCH tuple. ^1.2.0 will not pick 1.3.0-beta.1; a good default that prevents installing a beta by accident.

"Breaking Change" Relative to What?

To know when to bump MAJOR, you first have to decide whom the version number makes a promise to. There are three different cases.

For a library, the promise is about the public API and behavior. Microsoft's .NET library guidance splits breaking changes into three kinds: source breaking (errors when you recompile, for example a new overload that makes a call ambiguous), binary breaking (code compiled against the old version gets a MissingMethodException at run time, for example after adding a parameter to a method signature) and behavior breaking (same signature, different result). The guidance sees behavior changes as the most common kind and reminds you that even a bug fix can be breaking for users who leaned on the old, broken behavior. Two more items belong on this list: raising the minimum supported framework (dropping the net8.0 target) and moving a dependency whose types appear in your public API to a new major. Both can stop the user's unchanged code from compiling.

For an HTTP API, the promise is about the contract: endpoints, JSON fields, status codes. The version there usually lives in the URL or a header and is a single number (v1, v2); we covered the mechanics in the "ASP.NET Core Web API Versioning: URL, Query String and Header with Asp.Versioning" post of the ASP.NET Core Web API series. Versioning message and event contracts is a separate topic, covered in the Messaging and Event-Driven Architecture series. The version of the application you deploy and the version of the API contract are not the same thing: Bookstore.Api may ship a new build every week while the contract is still v1.

For a deployed application, nobody takes a dependency on the version number. A user visiting the Bookstore site does not ask "should I move to 2.7.0?"; you decide which version reaches them. In that case the MAJOR/MINOR distinction is just something to argue about: a meeting about whether a change is minor or major helps nobody. The job of an application's version number is different: answering "which build is running in production, and when did it ship?". For that, a date and a build number carry more information than three semantic numbers.

CalVer: Taking the Version from the Calendar

CalVer is not a specification but a naming habit. calver.org describes the parts with tokens: YYYY is the full year, YY the short year counted from 2000 without padding (26 for 2026), 0Y the zero-padded short year, M and 0M for the month in place of the older MM, and similar tokens for week and day. Plain counters such as MINOR and MICRO can be added. Well-known users: Ubuntu YY.0M (26.04), pip YY.MINOR.MICRO, JetBrains IDEs YYYY.MINOR.MICRO, Home Assistant YYYY.M.MICRO.

CalVer is strongest where you need to see a version's age at a glance. Someone who sees 2026.11.3 knows it is the third release of November 2026; that helps a lot when talking about support dates. Its weakness is that it says nothing about compatibility. That is why it is chosen for deployed applications rather than libraries.

If you use CalVer in .NET and npm, watch two technical limits. The first is zero padding: write 2026.04.1 in the 0M format and, as the first program's output showed, NuGet normalizes it to 2026.4.1 while npm's semver.valid rejects it outright (null). Use unpadded M in both ecosystems. The second is part size: the parts of .NET's AssemblyVersion and FileVersion cannot be larger than 65535. Try to squeeze the date into a single number and the build breaks:

$ dotnet build tools/SemverLab -p:Version=20261114.1.0
…/tools/SemverLab/obj/Debug/net10.0/SemverLab.AssemblyInfo.cs(19,55): error CS7034: The specified version string '20261114.1.0.0' does not conform to the required format - major[.minor[.build[.revision]]]

The YYYY.M.BUILD format (2026.11.3) stays below the limit in every part and is accepted by NuGet, npm and System.Version alike.

Reading Support Policies: .NET and Angular

A version number gains meaning when it is read together with a release schedule and a support window. The policies of Bookstore's two main dependencies are good examples for writing our own.

.NET ships a major version every November. Even-numbered versions are LTS and supported for three years; odd-numbered versions are STS and supported for two. According to Microsoft's support page, support for .NET 8 (LTS) and .NET 9 (STS) ended on the same day, November 10, 2026; support for .NET 10 and .NET 11 ends on November 14, 2028. Patches ship monthly, usually on Patch Tuesday (the second Tuesday of each month), and they are cumulative: you have to be on the latest patch to stay supported. Here the version number carries a calendar rather than a promise; .NET 11 is not "more breaking" than .NET 10.

Angular, on the other hand, uses SemVer and makes breaking changes only in major versions. According to Angular's release page, from v22 on major versions ship roughly every 12 months (they used to ship every 6 months); each major gets 12 months of active support followed by 12 months of LTS. During LTS only critical fixes and security patches land. Deprecated APIs stay for at least one major (about a year) and are removed only in a major release. According to the table on that page, v22 is active right now, and v21 and v20 are in LTS.

The lesson from the two policies: a version number lets users plan only if there is a written support policy behind it. In the last post of the series we will write the template of that policy for Bookstore.

A Decision Table for Bookstore

There are four versioned things in Bookstore, and each answers to a different audience:

ComponentFor whomSchemeExample
Bookstore.ApiOps team, support, logs and telemetryCalVer + build2026.11.3+5114f85
bookstore-webOps team and users reporting bugsCalVer + build2026.11.3+5114f85
Bookstore.Contracts (NuGet).NET projects using the packageSemVer 2.0.01.4.0, 2.0.0-rc.1
@bookstore/ui (npm)Angular projects using the packageSemVer 2.0.01.2.0, 1.3.0-next.0

The applications use CalVer because nobody references them with a range and "when did it ship" is the more valuable information. The commit hash goes at the end as build metadata; it does not affect ordering but tells you exactly which code it was built from. The packages use SemVer because other projects bind to them with ranges such as ^1.2.0 or Version="1.4.0", and those ranges only work if MAJOR is bumped honestly. The HTTP API contract is a separate axis: Bookstore.Api can serve /api/v1/books in version 2026.11.3 and in version 2027.2.1 alike.

In Production

A version number cannot be taken back once it is published, so write the rules down before the release. In a VERSIONING.md file, state which component uses which scheme, what triggers a MAJOR, and the names of the pre-release labels (alpha, beta, rc, all lower case). Fixing the label names also takes care of the case difference between NuGet and npm.

Always separate pre-release numbers with a dot: beta.2, not beta2. beta2 is a single alphanumeric identifier, so it is compared in ASCII order and beta10 comes before beta2. The SemVer 2.0 sorting example in the NuGet documentation shows alpha10 sorted before alpha2 as well.

Carrying the application version with every log line and in telemetry cuts the time to find which version a bug started with down to minutes. The .NET and Angular mechanics of that are the topic of the next post. Finally, make the version decision in one place: the same commit being labelled 1.4.0 in CI and 1.4.0-dev on a developer's machine is one of the most confusing situations there is.

Common Pitfalls

A major shipped as a minor. In Bookstore.Contracts 1.5.0 you only added a property, but in the same release you moved a dependency whose types appear in the public API to the next major, or dropped the net8.0 target. For the user that is a break; the SemVer FAQ counts a dependency update as compatible only if the public API does not change. A release that changes the target framework or the major of a public dependency is a MAJOR.

Never reaching 1.0. For packages that stay on 0.x for years because they are "not stable yet", npm treats every minor as breaking and users get stuck on narrow ranges such as ^0.4.0. If someone uses the package in production, it is time for 1.0.0.

Sorting versions as strings. Finding the "latest version" with sort in a deploy script puts 1.10.0 below 1.9.0. Always leave version comparison to a SemVer library.

Thinking build metadata makes a new version. 1.4.0+a1 and 1.4.0+b2 are the same version for NuGet; the feed will reject the second, and in a registry that accepts it, which one gets resolved is undefined.

Zero padding and single-number dates in CalVer. 2026.04.1 silently becomes 2026.4.1 in NuGet and is invalid in npm; a part such as 20261114 breaks the build in a .NET assembly version.

A Small Experiment

★☆☆ Add 1.0.0-beta.2.1, 1.0.0-beta.-1 and 1.0.0-0.3.7 to the list in SemverLab, write down your guess first, then run it. Which are valid, and where do the valid ones land in the order?

★★☆ Add the ranges ~0.2.3, ~1.2.3 and 1.2.x to semver-js and put their difference from ^ into a table. Try to express the same ranges with NuGet's VersionRange.Parse; which ones have no direct equivalent in NuGet?

★★★ Write a short VERSIONING.md for one library and one application of your team: the scheme, the list of changes that trigger a MAJOR (including minimum framework and public dependencies), pre-release labels and the CalVer format. Renumber your last three releases with these rules; how many of them change?

We talked about what a version number says; but if the number does not travel into the build output and the running application, nobody in production can see it. In the next post we look at .NET's Version, AssemblyVersion, FileVersion and InformationalVersion properties, Source Link, and a /version endpoint in the Bookstore API.