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
13 changes: 0 additions & 13 deletions .config/dotnet-tools.json

This file was deleted.

17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
root = true

[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.cs]
csharp_style_namespace_declarations = file_scoped:suggestion

[*.{csproj,props,targets,wxs,sln,json,yaml,yml}]
indent_size = 2

[*.md]
trim_trailing_whitespace = false
11 changes: 11 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Normalize line endings; keep Windows script types CRLF on checkout.
* text=auto

*.ps1 text eol=crlf
*.cmd text eol=crlf
*.bat text eol=crlf
*.sh text eol=lf

*.exe binary
*.msi binary
*.zip binary
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [master]
pull_request:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: windows-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- run: dotnet build rbmanager.sln
# The hosted image has VS with MSVC, so the RequiresVS and Publish
# suites run for real instead of skipping.
- run: dotnet test rbmanager.sln --no-build
6 changes: 1 addition & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,5 @@ dist/
*.msi
*.wixpdb
*.cab
rbmanager/bin/
rbmanager/obj/
rbmanager/publish/
rbmanager.Tests/bin/
rbmanager.Tests/obj/
artifacts/
TestResults/
14 changes: 14 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<!-- Centralize all build output under artifacts/ (SDK 8+ layout)
instead of per-project bin/ and obj/. -->
<UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>

<!-- WixToolset.Sdk does not implement the artifacts output layout,
so route .wixproj output there explicitly. -->
<PropertyGroup Condition="'$(MSBuildProjectExtension)' == '.wixproj'">
<BaseOutputPath>$(MSBuildThisFileDirectory)artifacts\bin\$(MSBuildProjectName)\</BaseOutputPath>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)artifacts\obj\$(MSBuildProjectName)\</BaseIntermediateOutputPath>
</PropertyGroup>
</Project>
62 changes: 47 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,48 +21,75 @@ after the language (`%LOCALAPPDATA%\Ruby`, like `%LocalAppData%\Python`)
rather than after the tool. rbmanager remains the product name.

```
rb setup copy rb itself onto PATH
rb install <zip|url> install a ruby binary package
rb list list installed rubies
rb use <version> switch the active ruby
rb uninstall <version> remove an installed ruby
rb setup [--yes] copy rb onto PATH and set up the VC++ runtime
rb install <zip|url> install a ruby binary package
rb list list installed rubies
rb use <version> switch the active ruby
rb uninstall <version> remove an installed ruby
rb enable [shell] print C++ build env to eval (cmd|powershell)
rb exec <command...> run a command with the C++ build env applied
```

rb is a bare exe; `setup` copies it to
`%LOCALAPPDATA%\Ruby\bin` and puts that directory on the user PATH,
which stands in for an installer until a winget manifest exists.
which stands in for an installer until the winget and MSI channels
ship. It also checks for the VC++ 2015-2022 redistributable the
official mswin packages depend on, and offers to download and install
it (signature-verified, elevated); `--yes` skips the consent prompt.

`enable` and `exec` are the `ridk enable` equivalent for building
C extension gems with MSVC; see
[docs/devkit-enable.md](docs/devkit-enable.md).

The active ruby is exposed through an NTFS directory junction
`%LOCALAPPDATA%\Ruby\current`, and `install` appends
`%LOCALAPPDATA%\Ruby\current\bin` to the user PATH once; switching
versions only re-points the junction. Junctions rather than symbolic
links because they need no privilege and no Developer Mode.

Build (requires the .NET 8 SDK and MSVC link.exe):
## Build and test

Regular development needs only the .NET 8 SDK (the feature band is
pinned by `global.json`):

```
dotnet build rbmanager.sln
dotnet test rbmanager.sln
```

Suites that need more than the SDK (Visual Studio, network access)
skip themselves when the environment lacks it; the category filters
for running them selectively are listed in
[docs/test-plan.md](docs/test-plan.md). CI
(`.github/workflows/ci.yml`) runs the same build and the full suite
on windows-latest, where VS is present.

Publishing the shipping rb.exe additionally requires MSVC link.exe:

```
dotnet publish rbmanager -r win-x64 -c Release -o rbmanager\publish
dotnet publish src\rbmanager -r win-x64 -c Release -o artifacts\publish\rbmanager
```

This produces a self-contained NativeAOT `rb.exe` (~5 MB) with no
This produces a self-contained NativeAOT `rb.exe` (~6 MB) with no
runtime dependency.

## rbmanager MSI

For channels that want a real installer instead of the bare exe,
`build-installer.ps1` wraps rb.exe in a per-user MSI
(`src/rbmanager.wxs`, WiX v5):
(`src/rbmanager.Installer/`, a WiX v5 SDK-style project):

```
.\build-installer.ps1 -Validate
.\build-installer.ps1
```

The MSI installs `rb.exe` into `%LOCALAPPDATA%\Ruby\bin` and puts that
directory on the user PATH, producing exactly the layout `rb setup`
creates, and removes both again on uninstall. rbmanager is a single MSI
product line; see [docs/upgrade-code.md](docs/upgrade-code.md). WiX
5.0.2 is pinned as a dotnet local tool in `.config/dotnet-tools.json`.
The output is unsigned; code signing is tracked separately.
5.0.2 comes in through the `WixToolset.Sdk` NuGet package, and ICE
validation runs as part of the build. The output is unsigned; code
signing is tracked separately.

An earlier iteration packaged each Ruby version as its own MSI. That
direction was dropped in favor of rbmanager; see the git history for
Expand All @@ -85,8 +112,13 @@ failure modes all degrade to the previous behavior.

## Layout

- `rbmanager/` — the version manager (C#, NativeAOT)
- `src/rbmanager/` — the version manager (C#, NativeAOT)
- `tests/rbmanager.Tests/` — the xUnit test suite
- `overlay/` — files layered onto every installed runtime
- `src/rbmanager.wxs`, `build-installer.ps1` — the rbmanager MSI
- `src/rbmanager.Installer/`, `build-installer.ps1` — the rbmanager MSI
- `winget/` — draft winget manifests
- `docs/` — design notes
- `tools/query.ps1` — dump MSI tables via the WindowsInstaller COM API

Build output goes to `artifacts/` (`UseArtifactsOutput` in
`Directory.Build.props`), never into the project directories.
65 changes: 13 additions & 52 deletions build-installer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@
Build the rbmanager MSI (rb.exe installer).

.DESCRIPTION
Publishes rb.exe with NativeAOT and compiles src/rbmanager.wxs with
WiX v5 into a per-user MSI under dist/. Code signing is a separate,
later step; the output is unsigned.
Publishes rb.exe with NativeAOT and builds src/rbmanager.Installer
(WiX v5, SDK-style) into a per-user MSI under dist/. ICE validation
runs as part of the build. Code signing is a separate, later step;
the output is unsigned.

.EXAMPLE
.\build-installer.ps1
.\build-installer.ps1 -Version 0.1.0 -Validate
.\build-installer.ps1 -Version 0.1.0
#>
param(
[string]$Version = '0.1.0',
[ValidateSet('x64', 'arm64')][string]$Arch = 'x64',
[string]$OutDir = (Join-Path $PSScriptRoot 'dist'),
[switch]$Validate,
[switch]$SkipPublish
)

Expand All @@ -25,69 +25,30 @@ if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "Version must be numeric x.y.z for MSI ProductVersion, got '$Version'"
}

# UUIDv5 derivation; see docs/upgrade-code.md. rbmanager is a single
# product line, so the arch is not part of its identity.
$NamespaceGuid = [guid]'E2C11F7E-A84E-4363-A0EB-D0A93E07E3CF'

function New-UuidV5([guid]$Namespace, [string]$Name) {
$ns = $Namespace.ToByteArray()
# GUID byte layout is little-endian in the first three fields; RFC 4122
# hashing requires network byte order.
[Array]::Reverse($ns, 0, 4); [Array]::Reverse($ns, 4, 2); [Array]::Reverse($ns, 6, 2)
$sha1 = [System.Security.Cryptography.SHA1]::Create()
try {
$hash = $sha1.ComputeHash($ns + [System.Text.Encoding]::UTF8.GetBytes($Name))
} finally {
$sha1.Dispose()
}
$b = $hash[0..15]
$b[6] = ($b[6] -band 0x0F) -bor 0x50 # version 5
$b[8] = ($b[8] -band 0x3F) -bor 0x80 # RFC 4122 variant
[Array]::Reverse($b, 0, 4); [Array]::Reverse($b, 4, 2); [Array]::Reverse($b, 6, 2)
[guid][byte[]]$b
}

$upgradeCode = New-UuidV5 $NamespaceGuid 'ruby-windows-installer:upgrade-code:rbmanager'
$pathGuid = New-UuidV5 $NamespaceGuid 'ruby-windows-installer:path-component:rbmanager:perUser'

$dotnet = Get-Command dotnet -ErrorAction SilentlyContinue
$dotnet = if ($dotnet) { $dotnet.Source } else { 'C:\Program Files\dotnet\dotnet.exe' }

$publishDir = Join-Path $PSScriptRoot 'rbmanager\publish'
$publishDir = Join-Path $PSScriptRoot 'artifacts\publish\rbmanager'
$rbExe = Join-Path $publishDir 'rb.exe'

Push-Location $PSScriptRoot
try {
if (-not $SkipPublish) {
Write-Host "Publishing rb.exe ($Arch) ..."
& $dotnet publish rbmanager -r "win-$Arch" -c Release -o $publishDir
& $dotnet publish src\rbmanager -r "win-$Arch" -c Release -o $publishDir
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
}
if (-not (Test-Path $rbExe)) { throw "$rbExe not found" }

& $dotnet tool restore | Out-Null
if ($LASTEXITCODE -ne 0) { throw "dotnet tool restore failed" }
Write-Host "Building the MSI ..."
& $dotnet build src\rbmanager.Installer -c Release `
"-p:ProductVersion=$Version" "-p:InstallerPlatform=$Arch" "-p:RbExe=$rbExe"
if ($LASTEXITCODE -ne 0) { throw "MSI build failed" }

New-Item -ItemType Directory -Force $OutDir | Out-Null
$msi = Join-Path $OutDir "rbmanager-$Version-$Arch.msi"

Write-Host "Building $msi ..."
& $dotnet wix build (Join-Path $PSScriptRoot 'src\rbmanager.wxs') `
-arch $Arch `
-d "Version=$Version" `
-d "UpgradeCode=$upgradeCode" `
-d "PathComponentGuid=$pathGuid" `
-d "RbExe=$rbExe" `
-o $msi
if ($LASTEXITCODE -ne 0) { throw "wix build failed" }

if ($Validate) {
Write-Host "Validating $msi ..."
# Per-user packages installing into the user profile trip ICE38/ICE64/
# ICE91 by design; those rules target per-machine packages.
& $dotnet wix msi validate '-sice' 'ICE38' '-sice' 'ICE64' '-sice' 'ICE91' $msi
if ($LASTEXITCODE -ne 0) { throw "wix msi validate failed" }
}
Copy-Item (Join-Path $PSScriptRoot "artifacts\bin\rbmanager.Installer\Release\rbmanager-$Version-$Arch.msi") `
$msi -Force
} finally {
Pop-Location
}
Expand Down
2 changes: 1 addition & 1 deletion docs/devkit-enable.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ should not depend on it.)

## Prototype

`rbmanager/Devkit.cs` implements both subcommands, wired into
`src/rbmanager/Devkit.cs` implements both subcommands, wired into
`Program.cs`'s dispatch switch as `rb enable [shell]` and
`rb exec <command...>`. It is ~180 lines, marked as a prototype, and
covers VS discovery, VsDevCmd activation with env-diffing, the
Expand Down
35 changes: 26 additions & 9 deletions docs/upgrade-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ requires.

## Derivation

UpgradeCodes are never allocated by hand. build-installer.ps1 derives
them as UUIDv5 (RFC 4122, SHA-1) from a fixed namespace GUID and a name
string:
UpgradeCodes are never allocated by hand. They are derived as UUIDv5
(RFC 4122, SHA-1) from a fixed namespace GUID and a name string, and
baked into `src/rbmanager.Installer/Package.wxs` as constants:

namespace: E2C11F7E-A84E-4363-A0EB-D0A93E07E3CF
name: ruby-windows-installer:upgrade-code:rbmanager
Expand All @@ -28,16 +28,33 @@ the repository rename and is frozen with the rest. Changing either
would silently break in-place upgrades, because new packages would stop
finding the installed ones.

Derived values for reference (reproducible with `New-UuidV5` in
build-installer.ps1):
Derived values for reference:

| name | UpgradeCode |
|------|-------------|
| name | GUID |
|------|------|
| ruby-windows-installer:upgrade-code:rbmanager | CAB95EEE-B5A6-52EA-94B3-B4413CE23855 |
| ruby-windows-installer:path-component:rbmanager:perUser | F786646B-2533-5C48-AD8F-9CCDD8AE4D16 |

The PATH environment component needs a stable GUID for the same reason:
The second entry is the PATH environment component, which needs a
stable GUID for the same reason. Both are reproducible with:

name: ruby-windows-installer:path-component:rbmanager:perUser
```powershell
function New-UuidV5([guid]$Namespace, [string]$Name) {
$ns = $Namespace.ToByteArray()
# GUID byte layout is little-endian in the first three fields; RFC 4122
# hashing requires network byte order.
[Array]::Reverse($ns, 0, 4); [Array]::Reverse($ns, 4, 2); [Array]::Reverse($ns, 6, 2)
$hash = [System.Security.Cryptography.SHA1]::HashData(
$ns + [System.Text.Encoding]::UTF8.GetBytes($Name))
$b = $hash[0..15]
$b[6] = ($b[6] -band 0x0F) -bor 0x50 # version 5
$b[8] = ($b[8] -band 0x3F) -bor 0x80 # RFC 4122 variant
[Array]::Reverse($b, 0, 4); [Array]::Reverse($b, 4, 2); [Array]::Reverse($b, 6, 2)
[guid][byte[]]$b
}
New-UuidV5 'E2C11F7E-A84E-4363-A0EB-D0A93E07E3CF' `
'ruby-windows-installer:upgrade-code:rbmanager'
```

The retired per-version Ruby MSI used the same scheme with one product
line per (series, arch) pair; its name formats and derived codes are in
Expand Down
6 changes: 6 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.400",
"rollForward": "latestFeature"
}
}
Loading
Loading