Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,15 @@ verified against the sha256 recorded in the index. An unsigned build
(all dev snapshots are unsigned) installs with a warning. A zip path or
URL skips the index and installs directly.

On ARM64 Windows `install` takes the arm64 build of a version or tag
and falls back to the x64 build, which runs under emulation, with a
warning when there is none. `use` makes the same choice among the
installed rubies. A full package name such as
`ruby-3.4.12-x64-mswin64_140` picks that build either way.

`list --remote` prints what the index currently offers for this
platform, newest first: the package name, the channel, and the tags
machine, newest first, with the arm64 builds above the x64 ones on
ARM64: the package name, the channel, the platform, and the tags
`install` accepts for it. It is the counterpart of `list`, which shows
what is installed, and it exists so that nothing outside rbmanager has
to fetch and interpret the feed.
Expand All @@ -74,7 +81,8 @@ install wins; `--vsver <2017|2019|2022|2026|latest>` (or the
`RBMANAGER_VSVER` environment variable) pins a specific Visual Studio
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.
`msvc` operations are spelled as flags. The environment targets x64,
so it cannot build extensions for an arm64 ruby.

`version` identifies the running binary, in cargo's shape, as in
`rbmanager 0.1.0 (9a1b2c3 2026-07-28)`. `--version` and `-V` print the
Expand Down
79 changes: 75 additions & 4 deletions docs/test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Command surface and contracts:
| Command | Behavior | Exit |
|---|---|---|
| `rb setup` | Copy own exe to `<root>\bin\rb.exe` (skip if already running from there, case-insensitive), append that dir to user PATH | 0 |
| `rb install <version\|zip\|url>` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching `x64-mswin64_140` build, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 |
| `rb install <version\|zip\|url>` | An argument that is not a URL and does not look like a zip path (no existing file, no path separator, no `.zip` suffix) is resolved through the binary index (see 4.13): pick the newest matching build of the first platform the host runs (`arm64-mswin64_140` then `x64-mswin64_140` on ARM64, `x64-mswin64_140` elsewhere, see 4.15), warn on stderr when an ARM64 host falls back to x64, download it, verify its `sha256`, warn on stderr when it is unsigned. If URL, download to `%TEMP%` first (deleted in `finally`). Zip must contain exactly one root dir named `ruby-*`; extract under `rubies`, inject the embedded `operating_system.rb` trust hook into `lib\ruby\site_ruby\rubygems\defaults\`, then switch `current` to it and ensure `current\bin` on user PATH. Refuse if already installed | 0 / 1 |
| `rb list` | Installed names sorted, active one starred | 0 |
| `rb use <query>` | Resolve query (exact or case-insensitive substring; must be unambiguous), recreate the `current` junction, ensure PATH | 0 / 1 |
| `rb use <query>` | Resolve query (exact or case-insensitive substring; must be unambiguous), on ARM64 let an arm64 match hide the x64 ones and warn as install does when landing on x64, recreate the `current` junction, ensure PATH | 0 / 1 |
| `rb uninstall <query>` | Resolve; if active, delete the junction first and print a hint; delete the install dir recursively | 0 / 1 |
| `rb msvc [--vsver <year>] [--] <cmd...>` | 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 <year>] [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 |
Expand Down Expand Up @@ -505,8 +505,8 @@ Unit (`BinaryIndexTests`):

Integration (`ListRemoteTests`, Serial):

121. Name, channel and the `install` tags, newest first, in aligned
columns.
121. Name, channel, platform and the `install` tags, newest first, in
aligned columns.
122. An index with nothing for this platform → empty stdout, a note on
stderr, exit 0.
123. `schema: 2` fails here as it does on install.
Expand All @@ -516,6 +516,77 @@ E2E (`CliE2eTests`):
124. `--remote` is the only flag `list` takes, and it takes no
argument; anything else is usage and exit 2.

### 4.15 ARM64 hosts

The index carries `arm64-mswin64_140` builds next to the x64 ones, with
tags assigned per platform. An ARM64 host prefers its own platform and
falls back to x64, which runs there under emulation. Any other host sees
only x64, as before. The host comes from
`RuntimeInformation.OSArchitecture`, which reports the machine even to an
emulated x64 process. `RBMANAGER_OS_ARCH` (an `Architecture` name)
overrides it, and `RbSandbox`/`E2eSandbox` pin it to `X64` so the other
suites stay host-independent.

Install and `list --remote` (`BinaryIndexTests` Unit, `ListRemoteTests`
and `InstallFromIndexTests` Integration, `PublishE2eTests` Publish):

125. `PlatformsFor(Arm64)` is arm64 then x64. X64 and X86 get x64 only.
126. On ARM64 a tag picks the arm64 build even when an x64 build of the
same tag is newer. An x64 host picks the x64 one.
127. A tag with no arm64 build falls back to x64.
128. A full x64 package name picks that build on ARM64.
129. `Available` on ARM64 lists the arm64 builds first, each platform
newest first.
130. `rb list --remote` on ARM64 lists the arm64 builds above the x64
ones, with the platform column.
131. `rb install <tag>` installs the older arm64 snapshot on ARM64 and the
x64 one on x64, without a warning.
132. No arm64 build for the tag on ARM64 → the x64 build installs and
stderr carries `warning: no arm64-mswin64_140 build matches '<q>',
falling back to x64-mswin64_140 under emulation`.
133. A full x64 name on ARM64 installs that build without the warning.
144. The win-x64 AOT exe lists the arm64 builds exactly when the machine
is ARM64, so emulation does not hide the host.
145. `Pick` ranks only the builds that match the query, so the version of
any other build is never parsed.
146. An `RBMANAGER_OS_ARCH` that names no `Architecture` member
(`arm64ec`, `99`) stops with an error naming the variable.

`rb use` (`ResolveTests` and `ProgramLifecycleTests`, Integration):

134. `Resolve(preferNative: true)` on ARM64 lets an arm64 match hide the
x64 ones, including a newer x64 version.
135. With no arm64 match it resolves the x64 ones as before.
136. A full x64 name still resolves to that build.
137. Two arm64 versions stay ambiguous, since the preference only orders
platforms.
138. On an x64 host, and without `preferNative` (what `uninstall` uses),
an arm64 and an x64 build of one version stay ambiguous.
139. `rb use` on ARM64 warns as install does only when it lands on x64 by
fallback, not for an arm64 match or a full x64 name.
147. When the matches span both platforms, the version the query spells
out beats the platform order: `3.4.1` picks an x64 3.4.1 over an
arm64 3.4.12.
148. Within one platform an exact version stays as ambiguous as before,
on x64 and ARM64 hosts alike.

VC++ runtime (`VcRedistTests`, Unit). `RBMANAGER_VC_REDIST_KEY` now
stands in for the `Runtimes` key, with `x64` and `ARM64` subkeys under
it.

140. `RuntimeOf` maps arm64 package names and the platform to `ARM64`,
everything else to `x64`.
141. `rb setup` on ARM64 is satisfied only when both runtimes are
registered.
142. With only the x64 runtime on ARM64, setup names `ARM64` as missing
and offers the x64 package.
143. `WarnIfMissing` checks an arm64 ruby against the ARM64 runtime and
an x64 ruby against the x64 one.
149. On ARM64 a `vcruntime140.dll` in the system directory does not stand
in for an unregistered runtime: setup still names `ARM64` as missing.
150. Nor does it for `IsInstalled` and `WarnIfMissing`, which warn for an
arm64 ruby. On x64 the DLL fallback is unchanged.

## 5. Execution plan

Phased so each phase leaves the tree green.
Expand Down
63 changes: 48 additions & 15 deletions src/rbmanager/BinaryIndex.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand All @@ -10,7 +11,32 @@ namespace RbManager;
// parsing `version`, whose syntax depends on the channel.
internal static class BinaryIndex
{
internal const string Platform = "x64-mswin64_140";
internal const string X64 = "x64-mswin64_140";
internal const string Arm64 = "arm64-mswin64_140";

// The platforms this machine runs, native first. ARM64 Windows runs
// x64 binaries under emulation, so x64 builds back up arm64 there.
internal static string[] Platforms => PlatformsFor(HostArchitecture());

internal static string[] PlatformsFor(Architecture host) =>
host == Architecture.Arm64 ? [Arm64, X64] : [X64];

// OSArchitecture reports the machine rather than the process, so
// rb-x64.exe under emulation still sees Arm64. RBMANAGER_OS_ARCH
// stands in for it in tests.
private static Architecture HostArchitecture()
{
if (Environment.GetEnvironmentVariable("RBMANAGER_OS_ARCH") is not { Length: > 0 } arch)
return RuntimeInformation.OSArchitecture;
string name = Enum.GetNames<Architecture>()
.FirstOrDefault(n => n.Equals(arch, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException(
$"RBMANAGER_OS_ARCH must name an Architecture such as X64 or Arm64, not '{arch}'");
return Enum.Parse<Architecture>(name);
}

internal static bool IsOn(string name, string platform) =>
name.EndsWith($"-{platform}", StringComparison.OrdinalIgnoreCase);

private const string DefaultUrl =
"https://cache.ruby-lang.org/pub/ruby/binaries/index.json";
Expand All @@ -23,10 +49,10 @@ internal static class BinaryIndex
: DefaultUrl;

public static async Task<Build> Resolve(string query) =>
Pick(await FetchAll(), query) ?? throw new InvalidOperationException(
Pick(await FetchAll(), query, Platforms) ?? throw new InvalidOperationException(
$"no binary package matches '{query}' in the index");

public static async Task<Build[]> Available() => Available(await FetchAll());
public static async Task<Build[]> Available() => Available(await FetchAll(), Platforms);

private static async Task<List<Build>> FetchAll()
{
Expand Down Expand Up @@ -62,20 +88,27 @@ internal static IndexPage Parse(string json)
return page;
}

// The newest match wins regardless of feed order: series tags like
// The first of `platforms` with a match wins, even over a newer build
// of a later one, because the feed assigns tags per platform. Within
// it the newest match wins regardless of feed order: series tags like
// "4.0" sit on every 4.0.x release, and dev tags like "4.1-dev" on
// every snapshot of the series.
internal static Build? Pick(IEnumerable<Build> builds, string query) =>
// every snapshot of the series. A full package name matches only its
// own build, so it is taken whatever its platform.
internal static Build? Pick(IEnumerable<Build> builds, string query, string[] platforms) =>
Ordered(builds.Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) ||
string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase)), platforms)
.FirstOrDefault();

// Everything installable here, in the order Pick resolves, so a tag
// always installs the topmost line carrying it.
internal static Build[] Available(IEnumerable<Build> builds, string[] platforms) =>
Ordered(builds, platforms).ToArray();

private static IEnumerable<Build> Ordered(IEnumerable<Build> builds, string[] platforms) =>
builds
.Where(b => b.Platform == Platform)
.Where(b => b.Tags.Contains(query, StringComparer.OrdinalIgnoreCase) ||
string.Equals(b.Name, query, StringComparison.OrdinalIgnoreCase))
.MaxBy(Rank);

// Everything installable here, newest first. Same order Pick resolves
// in, so a tag always installs the topmost line carrying it.
internal static Build[] Available(IEnumerable<Build> builds) =>
builds.Where(b => b.Platform == Platform).OrderByDescending(Rank).ToArray();
.Where(b => platforms.Contains(b.Platform))
.OrderBy(b => Array.IndexOf(platforms, b.Platform))
.ThenByDescending(Rank);

// Version, then reissue revision (SIGNING.md in ruby/actions), then
// commit date, which keeps this consistent with Program.Resolve's
Expand Down
52 changes: 45 additions & 7 deletions src/rbmanager/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ internal static async Task<int> Install(string source)
{
Build build = await BinaryIndex.Resolve(source);
Console.WriteLine($"Resolved {source} to {build.Name}");
WarnIfEmulated(source, build.Name);
if (!build.Signed)
Console.Error.WriteLine($"warning: {build.Name} is not code-signed");
source = build.Url;
Expand Down Expand Up @@ -144,7 +145,7 @@ internal static async Task<int> Install(string source)

Console.WriteLine($"Installed {name}");
Console.WriteLine("Open a new terminal to pick up PATH changes.");
VcRedist.WarnIfMissing();
VcRedist.WarnIfMissing(name);
return 0;
}
finally
Expand Down Expand Up @@ -194,26 +195,41 @@ internal static async Task<int> ListRemote()
Build[] builds = await BinaryIndex.Available();
if (builds.Length == 0)
{
Console.Error.WriteLine(
$"rb: the binary index offers no {BinaryIndex.Platform} builds");
Console.Error.WriteLine("rb: the binary index offers no " +
$"{string.Join(" or ", BinaryIndex.Platforms)} builds");
return 0;
}
int name = builds.Max(b => b.Name.Length);
int channel = builds.Max(b => b.Channel.Length);
int platform = builds.Max(b => b.Platform.Length);
foreach (Build b in builds)
Console.WriteLine($"{b.Name.PadRight(name)} {b.Channel.PadRight(channel)} " +
string.Join(", ", b.Tags));
$"{b.Platform.PadRight(platform)} " + string.Join(", ", b.Tags));
return 0;
}

internal static int Use(string query)
{
SwitchTo(Resolve(query));
string name = Resolve(query, preferNative: true);
SwitchTo(name);
Console.WriteLine($"Now using {CurrentTarget()}");
VcRedist.WarnIfMissing();
WarnIfEmulated(query, name);
VcRedist.WarnIfMissing(name);
return 0;
}

// For a query that had nothing on this machine's own platform. A full
// package name asked for that very build, so it is not a fallback.
private static void WarnIfEmulated(string query, string name)
{
string[] platforms = BinaryIndex.Platforms;
if (string.Equals(name, query, StringComparison.OrdinalIgnoreCase)) return;
if (platforms.Skip(1).FirstOrDefault(p => BinaryIndex.IsOn(name, p)) is { } fallback)
Console.Error.WriteLine(
$"warning: no {platforms[0]} build matches '{query}', " +
$"falling back to {fallback} under emulation");
}

internal static int Uninstall(string query)
{
string name = Resolve(query);
Expand Down Expand Up @@ -265,12 +281,13 @@ internal static string FormatVersion(string? informational, string? assembly, st
private static IEnumerable<string> InstalledRubies() =>
Directory.Exists(Rubies) ? Directory.EnumerateDirectories(Rubies).Order() : [];

internal static string Resolve(string query)
internal static string Resolve(string query, bool preferNative = false)
{
string[] matches = InstalledRubies()
.Select(Path.GetFileName)
.Where(n => n == query || n!.Contains(query, StringComparison.OrdinalIgnoreCase))
.ToArray()!;
if (preferNative) matches = FirstPlatformOnly(query, matches);
return matches switch
{
[var single] => single!,
Expand All @@ -280,6 +297,27 @@ internal static string Resolve(string query)
};
}

// The installed counterpart of BinaryIndex.Pick's platform order: the
// first of BinaryIndex.Platforms with a match hides the matches on the
// others. Names on none of them stay, so they remain as ambiguous as
// before. Across platforms the version the query spells out comes
// first, or "3.4.1" would land on an arm64 3.4.12 instead of an x64
// 3.4.1.
private static string[] FirstPlatformOnly(string query, string[] matches)
{
string[] platforms = BinaryIndex.Platforms;
string[] exact = matches
.Where(n => PackageName.Match(n) is { Success: true } m &&
m.Groups["ver"].Value.Equals(query, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (exact.Length > 0 && platforms.Count(p => matches.Any(n => BinaryIndex.IsOn(n, p))) > 1)
matches = exact;
string? first = platforms.FirstOrDefault(p => matches.Any(n => BinaryIndex.IsOn(n, p)));
return matches
.Where(n => !platforms.Any(p => p != first && BinaryIndex.IsOn(n, p)))
.ToArray();
}

// ruby-<version>[-<n>]-<platform>: a reissued release package
// (SIGNING.md in ruby/actions) differs from the original only by the
// numeric revision after the version. The revision never collides
Expand Down
Loading
Loading