diff --git a/Areas/Api/Controllers/VersionController.cs b/Areas/Api/Controllers/VersionController.cs
new file mode 100644
index 00000000..311a3ba9
--- /dev/null
+++ b/Areas/Api/Controllers/VersionController.cs
@@ -0,0 +1,35 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Wayfarer.Services;
+
+namespace Wayfarer.Areas.Api.Controllers;
+
+///
+/// Exposes the compiled Wayfarer application version.
+///
+[ApiController]
+[Area("Api")]
+[Route("api/version")]
+public sealed class VersionController : ControllerBase
+{
+ private readonly IAppVersionProvider _appVersionProvider;
+
+ ///
+ /// Creates the version API endpoint.
+ ///
+ /// The provider for the compiled application version.
+ public VersionController(IAppVersionProvider appVersionProvider)
+ {
+ _appVersionProvider = appVersionProvider;
+ }
+
+ ///
+ /// Gets the compiled Wayfarer application version.
+ ///
+ [HttpGet]
+ [AllowAnonymous]
+ public IActionResult Get()
+ {
+ return Ok(new { version = _appVersionProvider.Version });
+ }
+}
diff --git a/CommandLine/AppVersionCli.cs b/CommandLine/AppVersionCli.cs
new file mode 100644
index 00000000..918e9b8d
--- /dev/null
+++ b/CommandLine/AppVersionCli.cs
@@ -0,0 +1,37 @@
+using Wayfarer.Services;
+
+namespace Wayfarer.CommandLine;
+
+///
+/// Handles app-version CLI commands that do not require web host startup.
+///
+public static class AppVersionCli
+{
+ ///
+ /// Handles the version command when present.
+ ///
+ /// The process arguments.
+ /// The provider for the compiled application version.
+ /// The writer used for command output.
+ /// The writer used for command errors.
+ /// The command exit code when handled.
+ /// True when this handler consumed the command; otherwise false.
+ public static bool TryHandle(
+ string[] args,
+ IAppVersionProvider appVersionProvider,
+ TextWriter output,
+ TextWriter error,
+ out int exitCode)
+ {
+ _ = error;
+ exitCode = 0;
+
+ if (args.Length == 0 || !string.Equals(args[0], "version", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ output.WriteLine($"Wayfarer {appVersionProvider.Version}");
+ return true;
+ }
+}
diff --git a/Middleware/AppVersionHeaderMiddleware.cs b/Middleware/AppVersionHeaderMiddleware.cs
new file mode 100644
index 00000000..9ea9cc7c
--- /dev/null
+++ b/Middleware/AppVersionHeaderMiddleware.cs
@@ -0,0 +1,44 @@
+using Wayfarer.Services;
+
+namespace Wayfarer.Middleware;
+
+///
+/// Adds the compiled Wayfarer version to normal HTTP responses.
+///
+public sealed class AppVersionHeaderMiddleware
+{
+ ///
+ /// The response header that carries the Wayfarer version.
+ ///
+ public const string HeaderName = "X-Wayfarer-Version";
+
+ private readonly RequestDelegate _next;
+ private readonly IAppVersionProvider _appVersionProvider;
+
+ ///
+ /// Creates middleware that appends the Wayfarer version header.
+ ///
+ /// The next middleware in the pipeline.
+ /// The provider for the compiled application version.
+ public AppVersionHeaderMiddleware(RequestDelegate next, IAppVersionProvider appVersionProvider)
+ {
+ _next = next;
+ _appVersionProvider = appVersionProvider;
+ }
+
+ ///
+ /// Registers the version header before downstream middleware starts the response.
+ ///
+ /// The current HTTP context.
+ public async Task InvokeAsync(HttpContext context)
+ {
+ context.Response.OnStarting(static state =>
+ {
+ var (httpContext, version) = ((HttpContext, string))state;
+ httpContext.Response.Headers[HeaderName] = version;
+ return Task.CompletedTask;
+ }, (context, _appVersionProvider.Version));
+
+ await _next(context);
+ }
+}
diff --git a/Program.cs b/Program.cs
index 95889737..0f1955e4 100644
--- a/Program.cs
+++ b/Program.cs
@@ -16,6 +16,7 @@
using Quartz.Impl;
using Quartz.Spi;
using Serilog;
+using Wayfarer.CommandLine;
using Wayfarer.Jobs;
using Wayfarer.Middleware;
using Wayfarer.Models;
@@ -24,30 +25,29 @@
using Wayfarer.Swagger;
using Wayfarer.Util;
using IPNetwork = System.Net.IPNetwork;
-// for AddQuartz(), AddQuartzHostedService()
-// for UseMicrosoftDependencyInjectionJobFactory(), UsePersistentStore(), etc.
-// for IJobFactory
-// for UseNewtonsoftJsonSerializer()
+
+if (AppVersionCli.TryHandle(args, new AppVersionProvider(), Console.Out, Console.Error, out var versionExitCode))
+{
+ Environment.ExitCode = versionExitCode;
+ return;
+}
var builder = WebApplication.CreateBuilder(args);
#region CLI Command Handling
-// Handling the "reset-password" command from the CLI
if (args.Length > 0 && args[0] == "reset-password") { await HandlePasswordResetCommand(args); return; }
#endregion CLI Command Handling
#region Configuration Setup
-// Configuring the application settings, such as JSON configuration files
ConfigureConfiguration(builder);
#endregion Configuration Setup
#region Serilog Logging Setup
-// Setting up logging, including Serilog for file, console, and PostgreSQL logging
ConfigureLogging(builder);
#endregion Serilog Logging Setup
@@ -465,6 +465,7 @@ static void ConfigureServices(WebApplicationBuilder builder)
// Explicitly register IHttpContextAccessor for services that need it (e.g., TileCacheService).
// Some framework components may register it implicitly, but explicit registration is safer.
builder.Services.AddHttpContextAccessor();
+ builder.Services.AddSingleton();
// Register memory cache for application services
builder.Services.AddMemoryCache();
@@ -700,6 +701,7 @@ static async Task ConfigureMiddleware(WebApplication app)
// CRITICAL: Add this as the FIRST middleware to process forwarded headers from nginx
app.UseForwardedHeaders();
+ app.UseMiddleware();
app.UseMiddleware(); // Enriches Serilog LogContext with HttpContext.TraceIdentifier
app.UseMiddleware(); // Custom middleware for monitoring performance
diff --git a/Services/AppVersionDisplay.cs b/Services/AppVersionDisplay.cs
new file mode 100644
index 00000000..8481da63
--- /dev/null
+++ b/Services/AppVersionDisplay.cs
@@ -0,0 +1,17 @@
+namespace Wayfarer.Services;
+
+///
+/// Formats user-visible Wayfarer version text.
+///
+public static class AppVersionDisplay
+{
+ ///
+ /// Formats the shared footer version text.
+ ///
+ /// The provider for the compiled application version.
+ /// The footer version string.
+ public static string FooterText(IAppVersionProvider appVersionProvider)
+ {
+ return $"Wayfarer v{appVersionProvider.Version}";
+ }
+}
diff --git a/Services/AppVersionProvider.cs b/Services/AppVersionProvider.cs
new file mode 100644
index 00000000..3bea7930
--- /dev/null
+++ b/Services/AppVersionProvider.cs
@@ -0,0 +1,59 @@
+using System.Reflection;
+
+namespace Wayfarer.Services;
+
+///
+/// Provides the compiled Wayfarer application version.
+///
+public interface IAppVersionProvider
+{
+ ///
+ /// Gets the application release version from assembly informational metadata.
+ ///
+ string Version { get; }
+}
+
+///
+/// Reads the Wayfarer application version from assembly informational metadata.
+///
+public sealed class AppVersionProvider : IAppVersionProvider
+{
+ private readonly Assembly _assembly;
+ private string? _version;
+
+ ///
+ /// Creates a provider for the Wayfarer application assembly.
+ ///
+ public AppVersionProvider()
+ : this(typeof(AppVersionProvider).Assembly)
+ {
+ }
+
+ ///
+ /// Creates a provider for the assembly that contains the supplied marker type.
+ ///
+ /// A type from the assembly whose metadata should be read.
+ public AppVersionProvider(Type markerType)
+ : this(markerType.Assembly)
+ {
+ }
+
+ ///
+ /// Creates a provider for the supplied assembly.
+ ///
+ /// The assembly whose informational version should be read.
+ public AppVersionProvider(Assembly assembly)
+ {
+ _assembly = assembly;
+ }
+
+ ///
+ public string Version => _version ??= ReadInformationalVersion(_assembly);
+
+ private static string ReadInformationalVersion(Assembly assembly)
+ {
+ return assembly.GetCustomAttribute()?.InformationalVersion
+ ?? assembly.GetName().Version?.ToString()
+ ?? "0.0.0";
+ }
+}
diff --git a/Version.props b/Version.props
new file mode 100644
index 00000000..9ce09201
--- /dev/null
+++ b/Version.props
@@ -0,0 +1,9 @@
+
+
+ 1.4.0
+ $(WayfarerVersion)
+ $(WayfarerVersion)
+ $(WayfarerVersion)
+ false
+
+
diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml
index 19a9c814..decf1ebe 100644
--- a/Views/Shared/_Layout.cshtml
+++ b/Views/Shared/_Layout.cshtml
@@ -3,6 +3,7 @@
@using Wayfarer.Services
@using Wayfarer.Areas.Public.Controllers
@inject Wayfarer.Parsers.IApplicationSettingsService ApplicationSettingsService
+@inject IAppVersionProvider AppVersionProvider
@{
bool embed = ViewBag.IsEmbed as bool? ?? false;
}
@@ -143,7 +144,8 @@
Mobile -
Privacy
+ class="link-offset-2 link-offset-3-hover link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Privacy -
+ @AppVersionDisplay.FooterText(AppVersionProvider)
diff --git a/Wayfarer.csproj b/Wayfarer.csproj
index ba9cf7ed..55160c98 100644
--- a/Wayfarer.csproj
+++ b/Wayfarer.csproj
@@ -1,5 +1,7 @@
+
+
net10.0
enable
diff --git a/docs/23-Versioning.md b/docs/23-Versioning.md
new file mode 100644
index 00000000..993ff72e
--- /dev/null
+++ b/docs/23-Versioning.md
@@ -0,0 +1,32 @@
+# Versioning
+
+`Version.props` is the runtime and release version source for this slice. The root
+file contains the manually edited `WayfarerVersion` value and maps the standard
+MSBuild metadata directly from it:
+
+```xml
+1.4.0
+$(WayfarerVersion)
+$(WayfarerVersion)
+$(WayfarerVersion)
+false
+```
+
+The running app reads `AssemblyInformationalVersion` from the compiled Wayfarer
+assembly through `IAppVersionProvider`. Runtime surfaces such as
+`dotnet run --no-launch-profile -- version`, `GET /api/version`,
+`X-Wayfarer-Version`, and the shared layout footer use that provider instead of
+separate constants.
+
+Use `dotnet run --no-launch-profile -- version` when validating exact CLI
+output. The app writes exactly `Wayfarer 1.4.0`; `--no-launch-profile` avoids
+.NET SDK launch-profile messages so validation stays focused on app output.
+
+## Manual bump process
+
+To prepare a later release version, edit only the root `Version.props` file and
+change `WayfarerVersion` to the target release value. Rebuild the app so the new
+value is compiled into assembly metadata.
+
+Release helper automation, changelog checks, tag validation, and GitHub release
+validation are deferred to issue #324.
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index e5423c6e..467e40e7 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -23,4 +23,5 @@
- [Deployment](20-Deployment.md)
- [Security](21-Security.md)
- [Testing](22-Testing.md)
+ - [Versioning](23-Versioning.md)
diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs
new file mode 100644
index 00000000..0ee4fed8
--- /dev/null
+++ b/tests/Wayfarer.Tests/Versioning/AppVersionCliTests.cs
@@ -0,0 +1,55 @@
+using FluentAssertions;
+using Wayfarer.CommandLine;
+using Wayfarer.Services;
+using Xunit;
+
+namespace Wayfarer.Tests.Versioning;
+
+public class AppVersionCliTests
+{
+ [Fact]
+ public void TryHandle_VersionCommand_WritesExactVersionLine()
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ var handled = AppVersionCli.TryHandle(
+ new[] { "version" },
+ new StubAppVersionProvider("1.4.0"),
+ output,
+ error,
+ out var exitCode);
+
+ handled.Should().BeTrue();
+ exitCode.Should().Be(0);
+ output.ToString().Should().Be($"Wayfarer 1.4.0{Environment.NewLine}");
+ error.ToString().Should().BeEmpty();
+ }
+
+ [Fact]
+ public void TryHandle_VersionCommand_DoesNotRequireWebHostOrDatabaseStartup()
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ var handled = AppVersionCli.TryHandle(
+ new[] { "version" },
+ new StubAppVersionProvider("1.4.0"),
+ output,
+ error,
+ out var exitCode);
+
+ handled.Should().BeTrue();
+ exitCode.Should().Be(0);
+ }
+
+ private sealed class StubAppVersionProvider : IAppVersionProvider
+ {
+ public StubAppVersionProvider(string version)
+ {
+ Version = version;
+ }
+
+ public string Version { get; }
+ }
+}
diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs
new file mode 100644
index 00000000..f86e117b
--- /dev/null
+++ b/tests/Wayfarer.Tests/Versioning/AppVersionDisplayTests.cs
@@ -0,0 +1,46 @@
+using FluentAssertions;
+using Wayfarer.Services;
+using Xunit;
+
+namespace Wayfarer.Tests.Versioning;
+
+public class AppVersionDisplayTests
+{
+ [Fact]
+ public void FooterText_RendersSharedLayoutVersionText()
+ {
+ AppVersionDisplay.FooterText(new StubAppVersionProvider("1.4.0"))
+ .Should()
+ .Be("Wayfarer v1.4.0");
+ }
+
+ [Fact]
+ public void SharedLayout_UsesProviderBackedFooterHelper()
+ {
+ var layoutPath = Path.GetFullPath(Path.Combine(
+ AppContext.BaseDirectory,
+ "..",
+ "..",
+ "..",
+ "..",
+ "..",
+ "Views",
+ "Shared",
+ "_Layout.cshtml"));
+ var layout = File.ReadAllText(layoutPath);
+
+ layout.Should().Contain("@inject IAppVersionProvider AppVersionProvider");
+ layout.Should().Contain("@AppVersionDisplay.FooterText(AppVersionProvider)");
+ layout.Should().NotContain("Wayfarer v1.4.0");
+ }
+
+ private sealed class StubAppVersionProvider : IAppVersionProvider
+ {
+ public StubAppVersionProvider(string version)
+ {
+ Version = version;
+ }
+
+ public string Version { get; }
+ }
+}
diff --git a/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs b/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs
new file mode 100644
index 00000000..d43b1cfb
--- /dev/null
+++ b/tests/Wayfarer.Tests/Versioning/AppVersionProviderTests.cs
@@ -0,0 +1,53 @@
+using System.Reflection;
+using System.Reflection.Emit;
+using FluentAssertions;
+using Wayfarer.Services;
+using Xunit;
+
+namespace Wayfarer.Tests.Versioning;
+
+public class AppVersionProviderTests
+{
+ [Fact]
+ public void Version_DefaultProviderReadsCompiledWayfarerVersion()
+ {
+ var provider = new AppVersionProvider();
+
+ provider.Version.Should().Be("1.4.0");
+ }
+
+ [Fact]
+ public void Version_ReadsAssemblyInformationalVersion()
+ {
+ var assembly = CreateAssemblyWithInformationalVersion("9.8.7-test");
+
+ var provider = new AppVersionProvider(assembly);
+
+ provider.Version.Should().Be("9.8.7-test");
+ }
+
+ [Fact]
+ public void Version_CanUseMarkerTypeAssemblyInsteadOfEntryAssembly()
+ {
+ var provider = new AppVersionProvider(typeof(AppVersionProviderTests));
+ var expectedVersion = typeof(AppVersionProviderTests).Assembly
+ .GetCustomAttribute()!
+ .InformationalVersion;
+
+ provider.Version.Should().Be(expectedVersion);
+ Assert.NotSame(typeof(AppVersionProviderTests).Assembly, Assembly.GetEntryAssembly());
+ }
+
+ private static Assembly CreateAssemblyWithInformationalVersion(string version)
+ {
+ var attributeConstructor = typeof(AssemblyInformationalVersionAttribute)
+ .GetConstructor(new[] { typeof(string) })!;
+ var assemblyName = new AssemblyName($"WayfarerVersionTest{Guid.NewGuid():N}");
+ var informationalVersion = new CustomAttributeBuilder(attributeConstructor, new object[] { version });
+
+ return AssemblyBuilder.DefineDynamicAssembly(
+ assemblyName,
+ AssemblyBuilderAccess.Run,
+ new[] { informationalVersion });
+ }
+}
diff --git a/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs b/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs
new file mode 100644
index 00000000..05f3b674
--- /dev/null
+++ b/tests/Wayfarer.Tests/Versioning/VersionHttpTests.cs
@@ -0,0 +1,130 @@
+using System.Net;
+using System.Text.Json;
+using FluentAssertions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.AspNetCore.StaticFiles;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Wayfarer.Areas.Api.Controllers;
+using Wayfarer.Middleware;
+using Wayfarer.Services;
+using Xunit;
+
+namespace Wayfarer.Tests.Versioning;
+
+public class VersionHttpTests : IDisposable
+{
+ private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"wayfarer-version-tests-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task GetVersion_ReturnsExpectedJsonAndContentType()
+ {
+ using var host = await CreateApiHostAsync();
+ using var client = host.GetTestClient();
+
+ using var response = await client.GetAsync("/api/version");
+ var body = await response.Content.ReadAsStringAsync();
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Content.Headers.ContentType?.MediaType.Should().Be("application/json");
+ using var document = JsonDocument.Parse(body);
+ var property = document.RootElement.EnumerateObject().Should().ContainSingle().Subject;
+ property.Name.Should().Be("version");
+ property.Value.GetString().Should().Be("1.4.0");
+ }
+
+ [Fact]
+ public async Task VersionHeader_AppearsOnApiResponse()
+ {
+ using var host = await CreateApiHostAsync();
+ using var client = host.GetTestClient();
+
+ using var response = await client.GetAsync("/api/version");
+
+ response.Headers.GetValues(AppVersionHeaderMiddleware.HeaderName).Should().ContainSingle("1.4.0");
+ }
+
+ [Fact]
+ public async Task VersionHeader_AppearsOnDocsStaticResponse()
+ {
+ Directory.CreateDirectory(_tempDirectory);
+ await File.WriteAllTextAsync(Path.Combine(_tempDirectory, "version-test.txt"), "docs");
+
+ using var host = await CreateDocsStaticHostAsync(_tempDirectory);
+ using var client = host.GetTestClient();
+
+ using var response = await client.GetAsync("/docs/version-test.txt");
+
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ response.Headers.GetValues(AppVersionHeaderMiddleware.HeaderName).Should().ContainSingle("1.4.0");
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDirectory))
+ {
+ Directory.Delete(_tempDirectory, recursive: true);
+ }
+ }
+
+ private static async Task CreateApiHostAsync()
+ {
+ var host = new HostBuilder()
+ .ConfigureWebHost(webHost => webHost
+ .UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new StubAppVersionProvider("1.4.0"));
+ services.AddControllers()
+ .AddApplicationPart(typeof(VersionController).Assembly);
+ })
+ .Configure(app =>
+ {
+ app.UseMiddleware();
+ app.UseRouting();
+ app.UseEndpoints(endpoints => endpoints.MapControllers());
+ }))
+ .Build();
+
+ await host.StartAsync();
+ return host;
+ }
+
+ private static async Task CreateDocsStaticHostAsync(string docsPath)
+ {
+ var host = new HostBuilder()
+ .ConfigureWebHost(webHost => webHost
+ .UseTestServer()
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton(new StubAppVersionProvider("1.4.0"));
+ })
+ .Configure(app =>
+ {
+ app.UseMiddleware();
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = new PhysicalFileProvider(docsPath),
+ RequestPath = "/docs",
+ ContentTypeProvider = new FileExtensionContentTypeProvider()
+ });
+ }))
+ .Build();
+
+ await host.StartAsync();
+ return host;
+ }
+
+ private sealed class StubAppVersionProvider : IAppVersionProvider
+ {
+ public StubAppVersionProvider(string version)
+ {
+ Version = version;
+ }
+
+ public string Version { get; }
+ }
+}
diff --git a/tests/Wayfarer.Tests/Wayfarer.Tests.csproj b/tests/Wayfarer.Tests/Wayfarer.Tests.csproj
index 0331f910..d23e9c99 100644
--- a/tests/Wayfarer.Tests/Wayfarer.Tests.csproj
+++ b/tests/Wayfarer.Tests/Wayfarer.Tests.csproj
@@ -8,6 +8,7 @@
+
all