From 4dbe794d9fae6d4c3e79c2e5bd10583e237ab43f Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sun, 20 Sep 2026 16:31:27 +0100 Subject: [PATCH] version: generate IoxideRuntime.Version from the csproj instead of writing it twice Closes #224. The version was stated four ways and all four disagreed: README said 0.4.169, IoxideRuntime.Version said "0.0.17" - untouched since the 0.0.x days - the csprojs said 0.13.218, and nuget.org's newest was 0.7.211. Each release moved the csprojs and left the rest, because nothing tied them together. IoxideRuntime.Version is now generated. A target in ioxide.csproj writes a const into obj/ from the Version property beside it, so the single Version element a release already edits is the only statement of the number in the source tree. Generated rather than read back off AssemblyInformationalVersionAttribute, which is the obvious answer and the wrong one here: reflecting over assembly attributes is exactly what Native AOT trims. A generated const costs nothing at run time, survives trimming, and keeps Version a const - so this is not an API break for anyone using it in a const context, which reading it at run time would have been. It also sidesteps a detail that would have shipped: the SDK appends the commit sha to the informational version from the repo's git metadata, so that attribute currently reads "0.14.236+75c9bf6...". Deriving from it would have needed the suffix stripped, and nobody would have noticed until a caller compared the value against a package version. The README's blockquote loses its literal. The nuget badge at the top of the file already renders the live version from nuget.org, so the blockquote was the only hand-maintained copy left. Three tests, none of them using reflection - a test reaching for the attribute would be exercising something the shipped configuration cannot do. The one that matters reads out of ioxide.csproj and compares: putting the literal back fails it with "expected [0.14.236], got [0.0.17]", which is the exact drift being guarded. The issue's third item, that build.yml's publish step may not have run for 0.13.218, needs nothing: the flat-container index now lists 0.7.211, 0.13.218, 0.13.225, 0.13.233 and 0.14.236, and every main run since has been green. All suites green: Unit 46, E2E 185, Http 44, Tls 142, Chaos 47, File 4. --- README.md | 2 +- src/ioxide/IoxideRuntime.cs | 15 ++++- src/ioxide/ioxide.csproj | 35 ++++++++++++ tests/Ioxide.Tests.Unit/Program.cs | 1 + tests/Ioxide.Tests.Unit/VersionTests.cs | 75 +++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/Ioxide.Tests.Unit/VersionTests.cs diff --git a/README.md b/README.md index 4ebae3aa..529d8ba5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ioxide hands you raw bytes and stays out of HTTP; when you want a framework on t `ioxide.Kestrel` swaps the transport under an existing ASP.NET Core app with your endpoints unchanged. -> Linux 6.1+ · .NET 10 / .NET 11 · `0.14.236` - experimental +> Linux 6.1+ · .NET 10 / .NET 11 · experimental **[Documentation](https://mda2av.github.io/ioxide/)** - architecture, guides, and every example as runnable code side by side. diff --git a/src/ioxide/IoxideRuntime.cs b/src/ioxide/IoxideRuntime.cs index b7dbb96d..27e16916 100644 --- a/src/ioxide/IoxideRuntime.cs +++ b/src/ioxide/IoxideRuntime.cs @@ -7,7 +7,20 @@ namespace ioxide; /// public static class IoxideRuntime { - public const string Version = "0.14.236"; + /// + /// The version of the NuGet package this assembly shipped in. + /// + /// + /// Generated from the Version property in ioxide.csproj by the GenerateVersionSource target + /// there, not written here. As a hand-kept literal it had to be remembered on every release and + /// was not: it still read "0.0.17" - untouched since the 0.0.x days - against packages on + /// 0.13.233, one of four statements of the same number with all four disagreeing (#224). + /// + /// Generated rather than read off the assembly at run time: AssemblyInformationalVersionAttribute + /// would be the obvious source, but reflecting over assembly attributes is what Native AOT + /// trims. A const costs nothing and survives trimming. + /// + public const string Version = IoxideVersion.Value; // Wiring (a builder API will eventually wrap this): // var reactor = new Reactor(id, config); // implements IRingHost diff --git a/src/ioxide/ioxide.csproj b/src/ioxide/ioxide.csproj index f4d525b8..c4d36256 100644 --- a/src/ioxide/ioxide.csproj +++ b/src/ioxide/ioxide.csproj @@ -23,4 +23,39 @@ + + + + <_IoxideVersionFile>$(IntermediateOutputPath)IoxideVersion.g.cs + + + + <_IoxideVersionLines Include="// <auto-generated />" /> + <_IoxideVersionLines Include="// Written by the GenerateVersionSource target in ioxide.csproj, from its Version property." /> + <_IoxideVersionLines Include="// Do not edit, and do not commit: it lives under obj/." /> + <_IoxideVersionLines Include="namespace ioxide%3B" /> + <_IoxideVersionLines Include="internal static class IoxideVersion" /> + <_IoxideVersionLines Include="{" /> + <_IoxideVersionLines Include=" public const string Value = "$(Version)"%3B" /> + <_IoxideVersionLines Include="}" /> + + + + + + + + + diff --git a/tests/Ioxide.Tests.Unit/Program.cs b/tests/Ioxide.Tests.Unit/Program.cs index b6205aae..f8636f77 100644 --- a/tests/Ioxide.Tests.Unit/Program.cs +++ b/tests/Ioxide.Tests.Unit/Program.cs @@ -11,6 +11,7 @@ private static int Main() { var runner = new Runner(); + VersionTests.Register(runner); DemuxParseTests.Register(runner); MessageTests.Register(runner); ResponseCapTests.Register(runner); diff --git a/tests/Ioxide.Tests.Unit/VersionTests.cs b/tests/Ioxide.Tests.Unit/VersionTests.cs new file mode 100644 index 00000000..dc91fead --- /dev/null +++ b/tests/Ioxide.Tests.Unit/VersionTests.cs @@ -0,0 +1,75 @@ +using System.Text.RegularExpressions; +using ioxide; + +namespace Ioxide.Tests; + +/// +/// reports the version the package was built as. +/// +/// +/// It used to be a hand-kept literal, and literals rot: it read "0.0.17" against packages on +/// 0.13.233, untouched since the 0.0.x days while three other statements of the same number moved +/// without it (#224). It is now generated from ioxide.csproj's Version property, so the test worth +/// having compares it against that property - if the generator stops running, or someone puts the +/// literal back, these fail. +/// +/// No reflection anywhere here, deliberately: the whole reason the version is generated rather than +/// read off AssemblyInformationalVersionAttribute is that Native AOT trims attribute reflection, and +/// a test that reached for it would be testing something the shipped configuration cannot do. +/// +internal static class VersionTests +{ + public static void Register(Runner runner) + { + runner.Test("version: IoxideRuntime.Version matches ioxide.csproj", () => + { + string csproj = FindCsproj(); + Match declared = Regex.Match(File.ReadAllText(csproj), @"([^<]+)"); + + Assert.True(declared.Success, $"no element in {csproj}"); + Assert.Equal(declared.Groups[1].Value, IoxideRuntime.Version); + }); + + runner.Test("version: it is a real version, not a placeholder", () => + { + Assert.True(IoxideRuntime.Version.Length > 0, "Version is empty"); + + Assert.True(Version.TryParse(IoxideRuntime.Version, out Version? parsed) && parsed is not null, + $"Version is not parseable: '{IoxideRuntime.Version}'"); + + Assert.True(parsed!.Major > 0 || parsed.Minor > 0 || parsed.Build > 0, + $"Version is all zeroes: '{IoxideRuntime.Version}'"); + }); + + runner.Test("version: no build metadata leaks into it", () => + { + // The SDK appends "+" to the informational version from the repository's git + // metadata - this build's assembly attribute reads "0.14.236+75c9bf6...". Generating the + // const from the Version property sidesteps that entirely, and this pins it: callers + // compare the value against a package version, and no package is named with a sha on it. + Assert.True(!IoxideRuntime.Version.Contains('+'), + $"build metadata reached the reported version: '{IoxideRuntime.Version}'"); + }); + } + + /// + /// Walks up from the test binary to the repo root, identified by the solution file beside it. + /// Plain file system, no reflection - see the note on the class. + /// + private static string FindCsproj() + { + for (DirectoryInfo? at = new(AppContext.BaseDirectory); at is not null; at = at.Parent) + { + if (File.Exists(Path.Combine(at.FullName, "ioxide.slnx"))) + { + string csproj = Path.Combine(at.FullName, "src", "ioxide", "ioxide.csproj"); + + Assert.True(File.Exists(csproj), $"found the repo root at {at.FullName} but no {csproj}"); + return csproj; + } + } + + throw new Exception( + $"no ioxide.slnx above {AppContext.BaseDirectory} - this test reads the version out of the repo"); + } +}