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

Version Info in .NET and Angular: AssemblyVersion, FileVersion, InformationalVersion, Source Link and a /version Endpoint

Mert Özen Nov 14, 2026 13 min 2 views
Version Info in .NET and Angular: AssemblyVersion, FileVersion, InformationalVersion, Source Link and a /version Endpoint

In .NET, provide the version once through Version, pin AssemblyVersion to the major in libraries, put the CI build number into FileVersion and leave InformationalVersion to the SDK, which has appended the commit SHA since .NET 8. We make the version visible in the Bookstore API with a /version endpoint, a log line and OpenTelemetry service.version, inject it into the Angular build with define, and test the service worker's new-version notice with Playwright.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.402), OpenTelemetry .NET 1.21, Angular 22.2, Node.js 24, Playwright 1.65 and actionlint 1.7.12. The workflow was validated with actionlint; it was not run on GitHub.

Guess First

We added a small /version endpoint that returns its own version information as JSON to the API project of Bookstore, the online bookstore used as the shared example across this site's series. The project file does not have a single version-related line:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
  </PropertyGroup>

</Project>

The repository is a Git repository and the latest commit is af4ea8a. When you build with dotnet build and call the endpoint, what do you see in the version field: an empty value, 0.0.0, or 1.0.0? Does the commit show up anywhere?

$ dotnet build src/Bookstore.Api
$ curl -s http://localhost:5000/version
{"version":"1.0.0","commit":"af4ea8ae3c61bdfbc44fb46410c535c6f0508f41","assemblyVersion":"1.0.0.0","fileVersion":"1.0.0.0","runtime":".NET 10.0.13"}

When nobody says anything, the SDK assumes 1.0.0, and since .NET 8 it also appends the full commit SHA on its own. So every Bookstore build is labelled "1.0.0" even if you do nothing. In this post we replace these defaults with meaningful values: which MSBuild property controls what, why AssemblyVersion should stay fixed in a library, what Source Link adds, and how the version becomes visible in the running application, in logs, in telemetry and in the Angular UI.

The Four Versions of an Assembly

What we call "the version" in a .NET project is actually several separate properties. They all derive from the same source, but they are written to different places and do different jobs:

PropertyDefaultWritten toPurpose
VersionPrefix / VersionSuffix1.0.0 / emptyForms VersionFixed part in the project, suffix from CI
VersionPrefix + "-" + SuffixSource of all the othersSingle entry point
PackageVersionVersion.nupkgThe version NuGet sees
AssemblyVersionWithout suffix, 4 partsAssembly identityWhat the runtime checks when loading the assembly
FileVersionWithout suffix, 4 partsWin32 file resourceShown in file properties on Windows
InformationalVersionVersion + "+" + commitAttributeThe full version for humans

Microsoft's library versioning guidance says that of these four versions only AssemblyVersion affects runtime behavior, while FileVersion and InformationalVersion are informational. For FileVersion it recommends putting the CI build number in the fourth part; for InformationalVersion it recommends not setting it by hand and letting the SDK and Source Link generate it.

Centralizing with Directory.Build.props

Bookstore has two projects with different versioning rules: the API uses CalVer, the Bookstore.Contracts package uses SemVer. Shared settings live in Directory.Build.props at the repository root; MSBuild imports this file automatically before every project:

<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <Company>Bookstore</Company>
    <Product>Bookstore</Product>
  </PropertyGroup>

  <!-- Deterministic paths in CI: paths in the pdb start with /_/ -->
  <PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' Or '$(TF_BUILD)' == 'true'">
    <ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
  </PropertyGroup>
</Project>

The version number itself is per project. The library keeps the fixed part of its SemVer in its own file:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <VersionPrefix>1.1.0</VersionPrefix>
    <!-- Library: AssemblyVersion changes only when the major changes -->
    <AssemblyVersion>1.0.0.0</AssemblyVersion>
  </PropertyGroup>

</Project>

In the API, local builds come out as 0.0.0-dev; only CI provides the real version. That way, something built on a developer's machine cannot be confused with a production version:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <!-- CalVer: CI overrides this with -p:Version=YYYY.M.BUILD -->
    <VersionPrefix>0.0.0</VersionPrefix>
    <VersionSuffix>dev</VersionSuffix>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.21.0" />
    <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.21.0" />
    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.21.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Bookstore.Contracts\Bookstore.Contracts.csproj" />
  </ItemGroup>

</Project>

The VersionPrefix/VersionSuffix split is the shortest way to produce pre-releases in a library: dotnet pack -p:VersionSuffix=beta.1 produces the 1.1.0-beta.1 package and leaves the 1.1.0 in the project untouched. If you want to give the full version in CI, -p:Version= overrides the other two. In the next post we will derive these numbers from Git instead of writing them by hand; the properties here are the same ones those tools fill in.

AssemblyVersion and Binding: Why Pin It to the Major?

The AssemblyVersion line in Bookstore.Contracts.csproj is a deliberate choice. The guidance recommends putting only the major into a library's AssemblyVersion: 1.0 and 1.0.1 carry the same 1.0.0.0, 2.0 moves to 2.0.0.0. To see why, let's do the opposite and imitate a pipeline that changes AssemblyVersion on every build. We compile Contracts with build 57 and the API with build 58, then put the dll from build 57 next to the API; same source code, just one build apart:

$ dotnet build src/Bookstore.Contracts -c Release -p:AssemblyVersion=1.1.0.57 -o out/c57
$ dotnet build src/Bookstore.Api -c Release -p:AssemblyVersion=1.1.0.58 -o out/app
$ cp out/c57/Bookstore.Contracts.dll out/app/
$ dotnet out/app/Bookstore.Api.dll
Unhandled exception. System.IO.FileNotFoundException: Could not load file or assembly 'Bookstore.Contracts, Version=1.1.0.58, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.

File name: 'Bookstore.Contracts, Version=1.1.0.58, Culture=neutral, PublicKeyToken=null'
   at Program.<Main>$(String[] args)

The application does not even start. .NET Framework required the exact same version for a strong-named assembly, and you had to bridge the gap with binding redirects in app.config. .NET (Core) is more relaxed: according to the guidance, the runtime can load an assembly with a higher version than the one it was compiled against. But it does not load a lower one, and the error message is misleading: the file is there, what is missing is the requested version. A plugin folder, a deployment where two services share a dll, or a hotfix that replaces just one dll can easily end up here.

Pinning AssemblyVersion to the major ties this risk to a major change. A major already means breaking, so a binding error there is an expected outcome. The guidance has one more warning: never changing AssemblyVersion is also wrong, because a truly breaking release such as 2.0 could then silently replace the old 1.x. For application projects (like the API) the issue is much smaller because nobody references them; there it is fine for AssemblyVersion to follow CalVer.

Where did the commit hash in the guess section come from? With the .NET 8 SDK, Source Link became part of the SDK. As a side effect, InformationalVersion appends the SourceRevisionId property, the commit SHA, after Version. This change is on the .NET 8 SDK breaking change list because it affects code that parses InformationalVersion or shows it on screen as is. If you do not want it, you can turn it off:

$ dotnet build src/Bookstore.Api -c Release -p:IncludeSourceRevisionInInformationalVersion=false
$ curl -s http://localhost:5000/version
{"version":"0.0.0-dev","commit":null,"assemblyVersion":"0.0.0.0","fileVersion":"0.0.0.0","runtime":".NET 10.0.13"}

We keep it on; the AppVersion class splits the value at the + sign and puts the version and the commit into separate fields. As we saw in the previous post, the hash sitting in build metadata does not affect ordering.

Source Link's real job is debugging. During the build it embeds a small JSON into the pdb file that maps source file paths to raw file URLs in the repository. When the repository's origin remote points to an address on GitHub, the JSON embedded in the Contracts pdb looks like this:

// local build
{"documents":{"/home/user/src/bookstore/*":"https://raw.githubusercontent.com/example/bookstore/5e856fb6aadbd1ee1c7845696e1b57f5858f35fa/*"}}
// CI build (ContinuousIntegrationBuild=true)
{"documents":{"/_/*":"https://raw.githubusercontent.com/example/bookstore/5e856fb6aadbd1ee1c7845696e1b57f5858f35fa/*"}}

The difference between the two lines is ContinuousIntegrationBuild. With this property on, the compiler rewrites the paths in the pdb to the machine-independent /_/ prefix; the build produces the same output whichever folder it runs in. Deterministic is already on by default in .NET SDK projects; a fully deterministic build also needs ContinuousIntegrationBuild. We turn this property on only in CI, because if you turn it on locally the debugger cannot find the source files on your own disk. That is why the condition in Directory.Build.props looks at GITHUB_ACTIONS and Azure Pipelines' TF_BUILD variable. The Source Link built into the SDK recognizes GitHub, Azure Repos, GitLab and Bitbucket; if you use another Git server you need to add the matching Source Link package.

The Version in the Running Application: /version, Logs and OpenTelemetry

When investigating a bug in production, the first question is "which code is running?". In the "Build Once, Deploy Many" post of the CI/CD Fundamentals series we answered it with a small endpoint; here we extend the same idea to show all version properties. The record that reads the values from the assembly's attributes:

using System.Reflection;
using System.Runtime.InteropServices;

namespace Bookstore.Api;

public sealed record AppVersion(string Version, string? Commit, string AssemblyVersion, string FileVersion, string Runtime)
{
    public static AppVersion FromAssembly(Assembly assembly)
    {
        var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
        var fileVersion = assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "unknown";
        var assemblyVersion = assembly.GetName().Version?.ToString() ?? "unknown";

        // "2026.11.3+5114f85..." -> version and commit
        var parts = informational.Split('+', 2);
        return new(parts[0], parts.Length == 2 ? parts[1] : null, assemblyVersion, fileVersion, RuntimeInformation.FrameworkDescription);
    }
}

The API's Program.cs uses this value in three places: the /version endpoint, the startup log line and the OpenTelemetry resource:

using Bookstore.Api;
using Bookstore.Contracts;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

var appVersion = AppVersion.FromAssembly(typeof(Program).Assembly);

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService("bookstore-api", serviceVersion: appVersion.Version))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation()
        .AddConsoleExporter());

var app = builder.Build();

app.Logger.LogInformation("Bookstore.Api {Version} ({Commit}) starting", appVersion.Version, appVersion.Commit);

BookDto[] books =
[
    new("9780441172719", "Dune", "Frank Herbert", 420.00m),
    new("9780141439587", "Emma", "Jane Austen", 185.50m),
    new("9780547928227", "The Hobbit", "J. R. R. Tolkien", 310.00m),
];

app.MapGet("/api/books", () => books);
app.MapGet("/version", () => appVersion);

app.Run();

Let's build it with a version and a build number the way CI would, and call the endpoint:

$ dotnet build src/Bookstore.Api -c Release -p:Version=2026.11.3 -p:FileVersion=2026.11.3.57
$ curl -s http://localhost:5000/version
{"version":"2026.11.3","commit":"5e856fb6aadbd1ee1c7845696e1b57f5858f35fa","assemblyVersion":"2026.11.3.0","fileVersion":"2026.11.3.57","runtime":".NET 10.0.13"}

All four values are where we expect them: the CalVer version, the full commit, AssemblyVersion padded to 2026.11.3.0, and FileVersion carrying the CI build number in its fourth part. The line the application writes at startup makes it possible to filter logs by version:

info: Bookstore.Api[0]
      Bookstore.Api 2026.11.3 (5e856fb6aadbd1ee1c7845696e1b57f5858f35fa) starting

The most valuable part, though, is the OpenTelemetry counterpart. The serviceVersion passed to AddService is written to the service.version resource attribute defined in the semantic conventions and is attached to every trace, metric and log record from this service. You can see it in the console exporter's output:

Activity.DisplayName:        GET /version
Activity.Kind:               Server
...
Resource associated with Activity:
    service.name: bookstore-api
    service.version: 2026.11.3
    service.instance.id: e08ea85a-6493-4382-84dd-26203f689fbc
    telemetry.sdk.name: opentelemetry
    telemetry.sdk.language: dotnet
    telemetry.sdk.version: 1.21.0

Splitting the error rate by service.version on a dashboard answers "did the errors start with 2026.11.3?" with a single chart. We did not put the commit hash into service.version; the version number is for grouping, the commit is for searching in /version and the log line. We will cover setting up OpenTelemetry and its exporters in detail in the upcoming Observability series.

Angular: Injecting the Version at Build Time

We want to answer the same question in bookstore-web: when a user reports a bug, see which build is printed at the bottom of their screen. Angular's application builder offers the define option for this: it replaces a global identifier with a constant value at build time. We put a default for local builds into angular.json:

"options": {
  "browser": "src/main.ts",
  ...
  "define": {
    "APP_VERSION": "'0.0.0-dev'"
  }
}

Because the value is written into the code as is, strings have to be quoted; that is why the single quotes in '0.0.0-dev' are there. A small file so TypeScript knows the identifier:

declare const APP_VERSION: string;

// Replaced at build time by the "define" option or --define
export const appVersion: string = APP_VERSION;

CI overrides the same value from the command line. Let's check the result in the generated bundle:

$ npx ng build --define "APP_VERSION='2026.11.3+5114f85'"
$ grep -o "2026.11.3+5114f85" dist/bookstore-web/browser/main-*.js
2026.11.3+5114f85

The version shows up in the footer of the App component. This approach has a limit: because define runs at build time, the version stays correct when you deploy the same dist folder to different environments (same build, same version), but you should not pass environment-specific config this way. In the "Build Once, Deploy Many" post of the CI/CD Fundamentals series we explained why config belongs in a config.json read at runtime; the version is part of the build, config is part of the environment.

New Version Notifications with the Service Worker

Showing the version is not enough; the user's browser can stay on the old version. The Angular service worker caches the application's files and checks the ngsw.json manifest on the server on every start. If the manifest changed, it downloads the new version in the background and, when it is ready, emits a VERSION_READY event through SwUpdate.versionUpdates. After adding the service worker with ng generate service-worker, here is a small service that turns this event into a signal:

import { Injectable, inject, signal } from '@angular/core';
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
import { filter } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class VersionUpdate {
  private readonly swUpdate = inject(SwUpdate);
  readonly updateReady = signal(false);

  constructor() {
    if (!this.swUpdate.isEnabled) {
      return;
    }
    this.swUpdate.versionUpdates
      .pipe(filter((event): event is VersionReadyEvent => event.type === 'VERSION_READY'))
      .subscribe((event) => {
        console.log(`New version ready: ${event.latestVersion.hash}`);
        this.updateReady.set(true);
      });
  }

  async reload(): Promise<void> {
    await this.swUpdate.activateUpdate();
    document.location.reload();
  }
}

In the template, a banner bound to the signal, and the footer:

<header>
  <h1>Bookstore</h1>
</header>

@if (update.updateReady()) {
  <div class="update-banner" role="status">
    A new version is available.
    <button type="button" (click)="update.reload()">Reload</button>
  </div>
}

<main>
  <router-outlet />
</main>

<footer>bookstore-web {{ version }}</footer>

We tested this flow end to end with Playwright: we served the production build from a static server, opened the page and waited for the service worker to take control, then replaced the files on the server with the 2026.11.4 build and reloaded the page:

v1 footer: bookstore-web 2026.11.3+5114f85
v2 deployed
after reload footer: bookstore-web 2026.11.3+5114f85
[console] New version ready: 56c18610c53fde206bf4eb00c67461dfb755e871
banner: A new version is available. Reload
after click footer: bookstore-web 2026.11.4+9c41d2e

After the reload the user still sees the old version; the service worker serves from its cache and downloads the new version in the background. When the download finishes the banner appears, pressing the button activates the new version through activateUpdate, and the page opens with the new footer. Note the hash in the console: it is not the Bookstore version but the hash of the service worker manifest. If you want to tell the user "2026.11.4 is ready", you can put the version into the manifest through the appData field in ngsw-config.json; latestVersion.appData in the event carries that value.

In Production

Provide the version from one place. A release workflow that extracts the version from the Git tag and passes the same value to both the API and the web app:

name: release-app

on:
  push:
    tags: ['app/v*']

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
          cache-dependency-path: web/package-lock.json

      # app/v2026.11.3 -> 2026.11.3
      - name: Compute version
        id: version
        run: echo "version=${GITHUB_REF_NAME#app/v}" >> "$GITHUB_OUTPUT"

      - name: Publish API
        env:
          VERSION: ${{ steps.version.outputs.version }}
        run: >
          dotnet publish src/Bookstore.Api -c Release -o out/api
          -p:Version="$VERSION"
          -p:FileVersion="$VERSION.$GITHUB_RUN_NUMBER"

      - name: Build web
        working-directory: web
        env:
          VERSION: ${{ steps.version.outputs.version }}
        run: |
          npm ci
          npx ng build --define "APP_VERSION='$VERSION+${GITHUB_SHA::7}'"

The value from the tag is first put into an env variable and then used in the shell; writing a ${{ }} expression directly into a run line opens the door to the tag name being interpreted as a shell command. This workflow was validated with actionlint, and we ran its commands locally with the same variables; it was not run on GitHub. In the next post we will see more robust ways of deriving the version from tags automatically.

Do not expose /version to everyone. The full version and framework information is an easy hint for someone targeting a known vulnerability. Restrict the endpoint to the internal network, to the system running health checks, or to authorized users; even a header that gives only the version number to the outside world is usually unnecessary.

Keep version cardinality low in telemetry. Putting a different value per build (commit hash, build number) into service.version produces separate series per version in the metrics backend. The version number is enough; use the commit when searching logs and traces.

Check that ContinuousIntegrationBuild is really on in release builds. If the condition is written wrong it raises no error; the pdbs just keep the build machine's paths.

Common Pitfalls

Changing AssemblyVersion on every build with -p:Version=. If you do not pin AssemblyVersion in a library, Version drags it along and the binding error above becomes possible with every patch. Pin a library's AssemblyVersion to the major, or leave it to one of the tools in the next post.

Updating the version by hand. Before a release someone forgets to change the number in the csproj, and two different builds ship with the same version. The version should come from the tag or from CI; the number written in the repository should at most be a starting value.

Showing InformationalVersion on screen as is. Since .NET 8 this value carries a 40-character commit SHA; it can overflow an "About" screen or a User-Agent header. Split at the + before showing it.

Passing the define value without quotes in Angular. With --define APP_VERSION=2026.11.3 the build stops with Invalid define value (must be an entity name or JS literal): 2026.11.3, because the value is written into the code as is and 2026.11.3 is not a valid JavaScript expression. You have to give it as a quoted string. Worse, if the value happens to be a valid expression (such as 2026.10), the build succeeds, the value becomes a number and the footer shows 2026.1.

Adding the service worker but not thinking about the update flow. The user stays on the old version until they close the tab; if the API contract changed, the old client sends requests the new API does not accept. A banner, or at least a reload on the next navigation after VERSION_READY, should be part of the release process.

A Small Experiment

★☆☆ Pack Bookstore.Contracts with dotnet pack -p:VersionSuffix=beta.1 and guess the name of the .nupkg, and the AssemblyVersion and InformationalVersion of the dll inside it. Then open the package and check.

★★☆ Reverse the binding experiment: compile the API with AssemblyVersion=1.1.0.57 and Contracts with 1.1.0.58, and put the higher-version dll next to the API. Does the application start? Does the result match the guidance's statement that .NET (Core) loads a higher version?

★★★ Write a build step that adds the version to ngsw-config.json through appData, and make the banner say "2026.11.4 is available". Make sure the value comes from a single source (the tag).

In this post we always supplied the version from outside: in the csproj, on the command line, or cut from a tag. In the next post we compare three tools that compute the number directly from Git history, MinVer, Nerdbank.GitVersioning and GitVersion, on Bookstore.Contracts.