From e3165c548630a27b5286a75425a31a7eef459abb Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 20:43:39 +0900 Subject: [PATCH 1/2] Add rb version An upgraded rb.exe was otherwise only distinguishable by its file timestamp. The line follows cargo's shape, rbmanager 0.1.0 (9a1b2c3 2026-07-28), with the version coming from the release tag and the commit and date stamped in at build time, so two builds of the same tag stay tellable apart. Co-Authored-By: Claude Opus 5 --- README.md | 7 +++++++ src/rbmanager/Program.cs | 37 ++++++++++++++++++++++++++++++++++ src/rbmanager/rbmanager.csproj | 27 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/README.md b/README.md index 1aa86f5..5ddf372 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ rb uninstall remove an installed ruby rb msvc run a command with the MSVC build env applied rb msvc enable [shell] print the MSVC build env to eval (cmd|powershell) rb msvc --list list installed Visual Studio C++ toolchains +rb version print the rbmanager version ``` rb is a bare exe; `setup` copies it to @@ -50,6 +51,12 @@ version, and `msvc --list` shows what is installed. Apart from `enable`, every word after `msvc` is the command to run, so future `msvc` operations are spelled as flags. +`version` identifies the running binary, in cargo's shape, as in +`rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. The version is the release tag +the build came from, or the number in `rbmanager.csproj` between +releases. The commit and date are stamped in at build time, and are +omitted when there is no git checkout to read them from. + The official mswin packages deliberately do not bundle vcruntime140.dll (https://bugs.ruby-lang.org/issues/22180) and expect the machine-wide Microsoft Visual C++ Redistributable instead. That diff --git a/src/rbmanager/Program.cs b/src/rbmanager/Program.cs index 1db3c54..33711f2 100644 --- a/src/rbmanager/Program.cs +++ b/src/rbmanager/Program.cs @@ -29,6 +29,7 @@ private static async Task Main(string[] args) ["list"] => List(), ["use", var name] => Use(name), ["uninstall", var name] => Uninstall(name), + ["version"] => Version(), // Everything after `msvc` belongs to Msvc's own parser: it // owns one reserved word (`enable`) and passes the rest // through as the user's command line. @@ -58,6 +59,7 @@ list list installed rubies msvc enable [shell] print the MSVC build env to eval (cmd|powershell) msvc --list list installed Visual Studio C++ toolchains (msvc and msvc enable accept --vsver ) + version print the rbmanager version """); return 2; } @@ -158,6 +160,41 @@ internal static int Uninstall(string query) return 0; } + // Which build is this? An upgraded rb.exe is otherwise only + // distinguishable by its file timestamp. + private static int Version() + { + Console.WriteLine(SelfVersion()); + return 0; + } + + // Read back from the assembly rather than kept as a literal here. The + // release workflow stamps the version from the tag (-p:Version=) + // and rbmanager.csproj stamps the commit, so constants in this file + // would drift from the shipped exe. + internal static string SelfVersion() + { + Assembly self = Assembly.GetExecutingAssembly(); + return FormatVersion( + self.GetCustomAttribute()?.InformationalVersion, + self.GetName().Version?.ToString(), + self.GetCustomAttributes() + .FirstOrDefault(a => a.Key == "CommitDate")?.Value); + } + + // cargo's shape, `cargo 1.75.0 (1d8b05cdd 2023-11-20)`. The commit + // rides along as SourceRevisionId's "+" suffix on the + // informational version. A build with no git checkout to ask has + // neither commit nor date, and then only the version is printed. + internal static string FormatVersion(string? informational, string? assembly, string? date) + { + string[] parts = (informational is { Length: > 0 } ? informational + : assembly ?? "unknown").Split('+', 2); + string stamp = string.Join(' ', new[] { parts.Length > 1 ? parts[1] : null, date } + .Where(s => !string.IsNullOrEmpty(s))); + return stamp.Length == 0 ? $"rbmanager {parts[0]}" : $"rbmanager {parts[0]} ({stamp})"; + } + private static IEnumerable InstalledRubies() => Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : []; diff --git a/src/rbmanager/rbmanager.csproj b/src/rbmanager/rbmanager.csproj index d5176c6..87d6768 100644 --- a/src/rbmanager/rbmanager.csproj +++ b/src/rbmanager/rbmanager.csproj @@ -27,6 +27,33 @@ Copyright (c) 2026 Hiroshi SHIBATA + + + + + + + + + + + $(RbCommit) + + + + + + From 8782a0a9e41afda21f49c438368bbc74c14e823f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Tue, 28 Jul 2026 20:43:46 +0900 Subject: [PATCH 2/2] Add tests for rb version The publish case is the one that carries weight. The version is read from assembly attributes, and NativeAOT is where that lookup could come back empty while the CoreCLR build stays fine. Co-Authored-By: Claude Opus 5 --- docs/test-plan.md | 23 +++++++++++++ tests/rbmanager.Tests/CliE2eTests.cs | 12 +++++++ tests/rbmanager.Tests/PublishE2eTests.cs | 15 ++++++++ tests/rbmanager.Tests/VersionTests.cs | 44 ++++++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 tests/rbmanager.Tests/VersionTests.cs diff --git a/docs/test-plan.md b/docs/test-plan.md index 4876dd6..36231ea 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -37,6 +37,7 @@ Command surface and contracts: | `rb msvc [--vsver ] [--] ` | Locate VsDevCmd via vswhere (narrowed to the requested VS product year, if any; `--vsver` > `RBMANAGER_VSVER` > newest), compute the env delta of activation and apply it to a `cmd /s /c` child (so `.cmd` shims resolve); removes `NoDefaultCurrentDirectoryInExePath`; propagates the child's exit code. Options are leading-only; `--` ends option parsing. `enable` is the only reserved word after `msvc` | child / 1 | | `rb msvc enable [--vsver ] [shell]` | Same delta printed as per-shell assignments plus an unset of `NoDefaultCurrentDirectoryInExePath`. Shell defaults to PowerShell; `cmd`/`bat` selects cmd syntax. `--vsver` parses on either side of `enable`. No toolchain: actionable warning on stderr | 0 / 1 | | `rb msvc --list` | All installs carrying the MSVC toolset, newest first, `*` on the one default resolution would pick. Terminal: nothing may follow it. No toolchain: same warning as the other two | 0 / 1 / 2 | +| `rb version` | Print `rbmanager ( )` in cargo's shape, from the assembly's informational version (whose `+` suffix carries the commit) and the `CommitDate` assembly metadata. The parenthetical is dropped when the build had no git checkout, and the assembly version stands in for a missing informational version | 0 | | anything else | Usage text | 2 | Any thrown exception is caught in `Main`, printed as `rb: ` to @@ -390,6 +391,28 @@ Added with `--vsver`: the `LibraryImport` P/Invokes survive AOT; this is the one place the AOT binary differs meaningfully from the CoreCLR build). +### 4.12 Program: `rb version` — Unit + E2E + Publish + +Everything is read back from the assembly instead of a literal, so the +values are whatever the build stamped in and the tests pin the shape. + +88. (E2E) `rb version` → exit 0 and one line matching + `rbmanager ( )`, the parenthetical optional. +89. (Unit) `FormatVersion` with all three present → the full + cargo-shaped line. +90. (Unit) `FormatVersion` with the commit, the date, or both missing → + the parenthetical carries only what is there, or is dropped + entirely. A prerelease tag keeps its own `-rc1` suffix. +91. (Unit) `FormatVersion` falls back to the assembly version when there + is no informational version, and to `unknown` when there is neither. +92. (Unit) `SelfVersion` for the current build starts with `rbmanager ` + and a parseable version. +93. (Publish) The same command against the AOT exe prints exactly what + the in-process resolution returns. NativeAOT is the one place the + assembly-attribute lookup could come back empty, and both builds + come from the same source tree at the same commit, so any + divergence is AOT. + ## 5. Execution plan Phased so each phase leaves the tree green. diff --git a/tests/rbmanager.Tests/CliE2eTests.cs b/tests/rbmanager.Tests/CliE2eTests.cs index f548ede..980a845 100644 --- a/tests/rbmanager.Tests/CliE2eTests.cs +++ b/tests/rbmanager.Tests/CliE2eTests.cs @@ -58,6 +58,18 @@ public void FailingCommand_ErrorToStderr_Exit1_EmptyStdout() Assert.StartsWith("rb: ", r.Err); } + [Fact] // case 88 + public void Version_OneLine_Exit0() + { + using var sb = new E2eSandbox(); + RbResult r = sb.Run("version"); + + Assert.Equal(0, r.ExitCode); + string line = Assert.Single(Lines(r.Out)); + // cargo's shape, e.g. `rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. + Assert.Matches(@"^rbmanager \d+(\.\d+)+( \([0-9a-f]+ \d{4}-\d{2}-\d{2}\))?$", line); + } + [Fact] // case 38 public void FullLifecycle_ThroughProcessBoundary() { diff --git a/tests/rbmanager.Tests/PublishE2eTests.cs b/tests/rbmanager.Tests/PublishE2eTests.cs index 334d2ca..72a9e94 100644 --- a/tests/rbmanager.Tests/PublishE2eTests.cs +++ b/tests/rbmanager.Tests/PublishE2eTests.cs @@ -31,6 +31,21 @@ public void Install_UnderAot_WritesTrustHookAndSwitches() Assert.True(File.Exists(hook)); } + // Compared against the in-process resolution rather than a literal. + // Both come from the same source tree at the same commit, so any + // divergence is AOT dropping the assembly metadata `rb version` reads. + [SkippableFact] // case 93 + public void Version_UnderAot_MatchesTheInProcessResolution() + { + Skip.If(_fx.ExePath is null, _fx.SkipReason); + using var sb = new E2eSandbox(); + + RbResult r = sb.RunExe(_fx.ExePath!, "version"); + + Assert.Equal(0, r.ExitCode); + Assert.Equal(Program.SelfVersion(), r.Out.Trim()); + } + [SkippableFact] // case 32: self-copy skip works on the self-contained exe public void Setup_FromCopiedExe_NoSelfCopyError() { diff --git a/tests/rbmanager.Tests/VersionTests.cs b/tests/rbmanager.Tests/VersionTests.cs new file mode 100644 index 0000000..cf78932 --- /dev/null +++ b/tests/rbmanager.Tests/VersionTests.cs @@ -0,0 +1,44 @@ +namespace RbManager.Tests; + +// Plan 4.12: the line behind `rb version`, in cargo's shape. The values come +// from whatever the build stamped into the assembly, so these pin the +// formatting and the degraded cases rather than a literal. +[Trait("Category", "Unit")] +public class VersionTests +{ + [Fact] // case 89 + public void Format_VersionCommitAndDate() + { + Assert.Equal("rbmanager 0.1.0 (9a1b2c3 2026-07-28)", + Program.FormatVersion("0.1.0+9a1b2c3", "0.1.0.0", "2026-07-28")); + } + + [Theory] // case 90 + // No git checkout to ask: no suffix, no date, so no parenthetical. + [InlineData("0.1.0", null, "rbmanager 0.1.0")] + // The date is a separate attribute, so either half can go missing. + [InlineData("0.1.0+9a1b2c3", null, "rbmanager 0.1.0 (9a1b2c3)")] + [InlineData("0.1.0", "2026-07-28", "rbmanager 0.1.0 (2026-07-28)")] + // A prerelease tag keeps its own hyphenated suffix. + [InlineData("1.2.3-rc1+9a1b2c3", "2026-07-28", "rbmanager 1.2.3-rc1 (9a1b2c3 2026-07-28)")] + public void Format_PartialStamps(string? informational, string? date, string expected) => + Assert.Equal(expected, Program.FormatVersion(informational, "0.1.0.0", date)); + + [Theory] // case 91 + // Without the informational version the assembly version stands in, and + // with neither there is still a line to print. + [InlineData(null, "0.1.0.0", "rbmanager 0.1.0.0")] + [InlineData("", null, "rbmanager unknown")] + public void Format_FallsBackToTheAssemblyVersion( + string? informational, string? assembly, string expected) => + Assert.Equal(expected, Program.FormatVersion(informational, assembly, null)); + + [Fact] // case 92 + public void SelfVersion_ReportsThisBuild() + { + string line = Program.SelfVersion(); + + Assert.StartsWith("rbmanager ", line); + Assert.True(Version.TryParse(line.Split(' ')[1], out _), line); + } +}