From cfac71bd586a8e72fa980c1deb5554ab5eb240bd Mon Sep 17 00:00:00 2001 From: Largo Date: Wed, 28 Oct 2026 15:32:44 +0100 Subject: [PATCH 1/3] A Windows installer, in the style of RubyInstaller Util/installer/ironruby.iss is an Inno Setup 6 script for the win-x64 release archive: Util/installer/build-installer.sh unpacks the zip and compiles ironruby--win-x64-setup.exe, which installs the tree unchanged. - It installs into C:\IronRuby40-x64 by default, as RubyInstaller into C:\Ruby40-x64: igem install writes into the install tree, so the directory must be writable without admin rights, and Bundler's RUBYOPT, the Rails templates and mspec break on a path with a space. Another directory can be chosen; a space gets a warning, a directory that already holds other files is refused. - For the current user (the default, no admin) or for all users. It puts the install directory first on that PATH, so ironruby, ir, irb and igem work from a new terminal. - Running .rb files with IronRuby is an option, off by default, and gives .rb back to the program it had before (RubyInstaller's RubyFile) on uninstall. - The uninstaller removes the files, Src as a whole with the gems installed since, and the PATH entry, leaving the rest of PATH as it was. release.yml builds it in the win-x64 package job with Inno Setup 6.7.3, downloaded from its GitHub release and checked against the asset's SHA-256, then installs, runs and uninstalls it with Util/installer/test-installer.ps1, once for the current user and once for all users: ironruby -v, ir, irb and igem install rack from the PATH a new terminal gets, and no file or PATH entry left afterwards. It is attested with the same pinned actions/attest step as the archives and published next to the zip. --- .github/workflows/release.yml | 50 +++++- Docs/releases/v4.0.3.md | 32 ++++ README.md | 19 +- Util/installer/build-installer.sh | 55 ++++++ Util/installer/ironruby.iss | 286 ++++++++++++++++++++++++++++++ Util/installer/test-installer.ps1 | 112 ++++++++++++ 6 files changed, 546 insertions(+), 8 deletions(-) create mode 100644 Docs/releases/v4.0.3.md create mode 100755 Util/installer/build-installer.sh create mode 100644 Util/installer/ironruby.iss create mode 100644 Util/installer/test-installer.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 207220ee6..ae91a067b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,8 @@ name: Release # Binary builds: a self-contained IronRuby for each platform, made by -# Util/package-release.sh. Pushing a tag v* publishes them as a GitHub release (a +# Util/package-release.sh, and for Windows also an installer made from that archive by +# Util/installer/build-installer.sh. Pushing a tag v* publishes them as a GitHub release (a # pre-release while the version has a suffix such as -preview1), with # Docs/releases/.md as the notes when there is one; running the workflow by hand, or # a pull request that changes the packaging, only keeps them as workflow artifacts. @@ -18,6 +19,7 @@ on: paths: - .github/workflows/release.yml - Util/package-release.sh + - Util/installer/** workflow_dispatch: inputs: render_video: @@ -159,16 +161,55 @@ jobs: working-directory: dist run: cd ironruby-*-win-x64 && cmd //c ir.cmd Samples/Notepad/test_notepad.rb + # Inno Setup, for the installer: its release from GitHub, pinned by version and by the + # SHA-256 GitHub lists for that asset, installed for this job only. + - name: Inno Setup + if: runner.os == 'Windows' + env: + INNO_VERSION: 6.7.3 + INNO_SHA256: 9c73c3bae7ed48d44112a0f48e66742c00090bdb5bef71d9d3c056c66e97b732 + run: | + mkdir -p "$RUNNER_TEMP/innosetup-download" + setup="$RUNNER_TEMP/innosetup-download/innosetup-$INNO_VERSION.exe" + curl -fsSL -o "$setup" \ + "https://github.com/jrsoftware/issrc/releases/download/is-${INNO_VERSION//./_}/innosetup-$INNO_VERSION.exe" + echo "$INNO_SHA256 $setup" | sha256sum -c + MSYS2_ARG_CONV_EXCL='*' "$setup" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /CURRENTUSER \ + "/DIR=$(cygpath -w "$RUNNER_TEMP/innosetup")" + test -f "$RUNNER_TEMP/innosetup/ISCC.exe" + echo "ISCC=$RUNNER_TEMP/innosetup/ISCC.exe" >> "$GITHUB_ENV" + + # The installer carries the archive just made, unchanged. + - name: Installer, from the archive + if: runner.os == 'Windows' + working-directory: ironruby + run: Util/installer/build-installer.sh "$GITHUB_WORKSPACE"/dist/ironruby-*-win-x64.zip + + # Into C:\IronRuby40-x64, for this user and then for all users: ironruby, irb and + # `igem install rack` from the PATH a new terminal gets, then the uninstaller, which + # must leave no file and the PATH as it was. + - name: Install, run and uninstall + if: runner.os == 'Windows' + timeout-minutes: 15 + shell: pwsh + working-directory: dist + run: | + $test = "$env:GITHUB_WORKSPACE\ironruby\Util\installer\test-installer.ps1" + $setup = (Get-Item ironruby-*-win-x64-setup.exe).FullName + & $test $setup + if ($LASTEXITCODE) { exit $LASTEXITCODE } + & $test $setup -AllUsers + # Signed SLSA build provenance for the archive a tag publishes, with GitHub's own # actions/attest, pinned to the commit of its v4.2.2 tag: `gh attestation verify # --repo Largo/ironruby` shows that this workflow built it, from which - # commit and in which run. dist/ironruby--.* is the archive alone, not - # the directory it was packed from. + # commit and in which run. dist/ironruby--*.* is the archive, and on + # Windows the installer, -setup.exe; not the directory the archive was packed from. - name: Attest build provenance if: github.event_name == 'push' && github.ref_type == 'tag' uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: dist/ironruby-*-${{ matrix.rid }}.* + subject-path: dist/ironruby-*-${{ matrix.rid }}*.* - uses: actions/upload-artifact@v7 with: @@ -176,6 +217,7 @@ jobs: path: | dist/*.tar.gz dist/*.zip + dist/*-setup.exe if-no-files-found: error # media/launch is a HyperFrames composition (media/launch/README.md). A video that diff --git a/Docs/releases/v4.0.3.md b/Docs/releases/v4.0.3.md new file mode 100644 index 000000000..9553069ca --- /dev/null +++ b/Docs/releases/v4.0.3.md @@ -0,0 +1,32 @@ +### Download + +Self-contained: unpack one and run `./ironruby`, `./irb.sh` and `./igem.sh` - on Windows +`ironruby.cmd`, `irb.cmd` and `igem.cmd`. No .NET to install. Or, on Windows, run the +installer. + +- **Windows x64 installer**: `ironruby-4.0.3-win-x64-setup.exe`, the zip below, installed + into `C:\IronRuby40-x64` and put on the PATH +- **Windows x64**: `ironruby-4.0.3-win-x64.zip`, with Windows Forms, WPF, `ironrubyw.exe` + and `Samples\Notepad\notepad.cmd` +- **Linux x64**: `ironruby-4.0.3-linux-x64.tar.gz` +- **macOS arm64**: `ironruby-4.0.3-osx-arm64.tar.gz`, built in CI, not tested there + +`SHA256SUMS` lists the checksums. Each file and `SHA256SUMS` has a signed build +provenance attestation, which says which workflow built it, from which commit, in which run: + +``` +gh attestation verify ironruby-4.0.3-win-x64-setup.exe --repo Largo/ironruby +``` + +### New + +- **A Windows installer**, in the style of RubyInstaller. It installs the win-x64 zip's + tree, unchanged, into `C:\IronRuby40-x64` by default, or a directory you choose, for you + alone (no admin rights) or for all users, and puts that directory first on the PATH, so + `ironruby`, `ir`, `irb` and `igem` work from a new terminal. `igem install` writes into + the install tree, which is why the default is a directory you can write to and has no + space in its path: Bundler's `RUBYOPT`, the Rails templates and mspec break on one. + Running `.rb` files with IronRuby is an option, off by default. The uninstaller removes + the files, the gems installed since, and the PATH entry. It is built in CI with Inno + Setup 6.7.3 from `Util/installer/ironruby.iss`, and installed, run and uninstalled there + before it is published. diff --git a/README.md b/README.md index 6e81e171f..a48bf4bff 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,16 @@ $ ./igem.sh install rack From 4.0.2 on, the archives have `ironruby` and `ironruby.cmd` (see above), and `ir.sh` / `ir.cmd` keep working; 4.0.1 and earlier have only those. On Windows, unzip it and use `ironruby.cmd`, `irb.cmd` and `igem.cmd`, or double-click -`Samples\Notepad\notepad.cmd`. The archive has the same layout as a checkout, with the +`Samples\Notepad\notepad.cmd`. From 4.0.3 on there is also an installer, +`ironruby--win-x64-setup.exe`, in the style of RubyInstaller: it installs the same +tree into `C:\IronRuby40-x64` (or a directory you choose), for you or for all users, puts +that directory first on your PATH so `ironruby`, `ir`, `irb` and `igem` work from a new +terminal, and its uninstaller removes it all again, installed gems included. It needs no +admin rights for you alone, and keeps the default path free of spaces, which Bundler, the +Rails templates and mspec do not handle. IronRuby's `irb` then comes before CRuby's on the +PATH; CRuby's `ruby` and `gem` are not affected. `Util/installer/build-installer.sh` makes +it from the zip with [Inno Setup](https://jrsoftware.org/isinfo.php), from +`Util/installer/ironruby.iss`. The archive has the same layout as a checkout, with the Release build in `Src/Console/bin/Release/net8.0`, so everything above applies to it unchanged. `irubyc` works too, but it still needs the .NET SDK to compile the application. The Windows archive also carries the Windows Desktop runtime, for Windows Forms and WPF, and @@ -521,11 +530,13 @@ The Windows archive also carries the Windows Desktop runtime, for Windows Forms bundles, Microsoft's license and third-party notices. `Util/package-release.sh ` builds such an archive from a checkout, and -`.github/workflows/release.yml` builds all three and publishes them when a `v*` tag is -pushed; the [launch video](media/launch) is rendered and added by hand. +`.github/workflows/release.yml` builds all three, and the Windows installer from the zip, +installs and uninstalls it once for the current user and once for all users, and +publishes them when a `v*` tag is pushed; the [launch video](media/launch) is rendered and +added by hand. Releases from 4.0.2 on carry a signed [build provenance attestation](https://docs.github.com/en/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) -for each archive and for `SHA256SUMS`, made by GitHub's `actions/attest` in the job that +for each archive (from 4.0.3 on, the installer too) and for `SHA256SUMS`, made by GitHub's `actions/attest` in the job that built the file. It says which workflow built the file, from which commit, in which run, and that the file is the one it built. Check one with the GitHub CLI: diff --git a/Util/installer/build-installer.sh b/Util/installer/build-installer.sh new file mode 100755 index 000000000..77108c920 --- /dev/null +++ b/Util/installer/build-installer.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Builds IronRuby's Windows installer (ironruby.iss, Inno Setup 6) from the win-x64 +# release archive that Util/package-release.sh makes: +# +# Util/installer/build-installer.sh dist/ironruby-4.0.2-win-x64.zip +# -> dist/ironruby-4.0.2-win-x64-setup.exe +# ISCC=/path/to/ISCC.exe Util/installer/build-installer.sh ZIP OUTDIR +# +# The installer carries the archive's tree as it is: the archive is unpacked into a +# directory of its own and handed to Inno Setup's compiler, and the version comes from +# the archive's name. ISCC is taken from $ISCC, from PATH or from where Inno Setup 6 +# installs itself. Runs in Git Bash on Windows. +set -euo pipefail + +ZIP=${1:?usage: Util/installer/build-installer.sh ironruby-VERSION-win-x64.zip [OUTDIR]} +ISS=$(cd "$(dirname "$0")" && pwd)/ironruby.iss +ZIP=$(cd "$(dirname "$ZIP")" && pwd)/$(basename "$ZIP") +OUT=$(mkdir -p "${2:-$(dirname "$ZIP")}" && cd "${2:-$(dirname "$ZIP")}" && pwd) +NAME=$(basename "$ZIP" .zip) +case "$NAME" in + ironruby-*-win-x64) ;; + *) echo "build-installer.sh: not a win-x64 release archive: $ZIP" >&2; exit 1 ;; +esac +VERSION=${NAME#ironruby-} +VERSION=${VERSION%-win-x64} +# 4.0.3-preview1 and 4.0.2+1a2b3c4 are 4.0.3 and 4.0.2 to Windows' file version. +NUMERIC=$(echo "$VERSION" | grep -oE '^[0-9]+(\.[0-9]+){0,3}') + +if [ -z "${ISCC:-}" ]; then + for candidate in "$(command -v iscc || true)" \ + "$(cygpath -u "${LOCALAPPDATA:-C:\\}")/Programs/Inno Setup 6/ISCC.exe" \ + "/c/Program Files (x86)/Inno Setup 6/ISCC.exe" \ + "/c/Program Files/Inno Setup 6/ISCC.exe"; do + if [ -n "$candidate" ] && [ -f "$candidate" ]; then ISCC=$candidate; break; fi + done +fi +if [ -z "${ISCC:-}" ]; then + echo "build-installer.sh: no Inno Setup 6 compiler (ISCC.exe); install Inno Setup 6 or set ISCC" >&2 + exit 1 +fi + +# Unpacked into the temporary directory, not next to the output: ISCC does not read paths +# past Windows' 260 characters, and the tree's own go 100 deep. +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +if command -v unzip >/dev/null; then unzip -q "$ZIP" -d "$STAGE" +else 7z x -bd -o"$STAGE" "$ZIP" >/dev/null; fi +test -f "$STAGE/$NAME/ironruby.cmd" + +# Not MSYS's path conversion: it would take ISCC's /D and /O switches for paths. +MSYS2_ARG_CONV_EXCL='*' "$ISCC" /Q \ + "/DAppVersion=$VERSION" "/DNumericVersion=$NUMERIC" \ + "/DSourceDir=$(cygpath -w "$STAGE/$NAME")" \ + "/O$(cygpath -w "$OUT")" "$(cygpath -w "$ISS")" +echo "installer: $OUT/$NAME-setup.exe" diff --git a/Util/installer/ironruby.iss b/Util/installer/ironruby.iss new file mode 100644 index 000000000..fa8fa0819 --- /dev/null +++ b/Util/installer/ironruby.iss @@ -0,0 +1,286 @@ +; IronRuby's Windows installer, in the style of RubyInstaller, for Inno Setup 6. +; +; Util/installer/build-installer.sh builds it from the win-x64 release archive that +; Util/package-release.sh makes, and installs the archive's tree unchanged: +; +; Util/installer/build-installer.sh dist/ironruby-4.0.2-win-x64.zip +; -> dist/ironruby-4.0.2-win-x64-setup.exe +; +; It installs into C:\IronRuby40-x64 by default, as RubyInstaller installs into +; C:\Ruby40-x64: `igem install` writes the gems into the install tree +; (Src\StdLib\ruby\gems) and their binstubs next to ir.exe, so the directory must be one +; the user can write to without admin rights, and a path with a space in it breaks +; Bundler's RUBYOPT, Rails' templates and mspec. Setup asks whether to install for the +; current user or for all users; either way it can add the install directory, where +; ironruby.cmd, ir.cmd, irb.cmd and igem.cmd are, to the front of that user's or the +; system's PATH. The uninstaller removes the files, whatever IronRuby wrote under Src +; since (installed gems included), and the PATH entry. +; +; build-installer.sh passes these; to run ISCC by hand, pass them the same way: +; /DAppVersion=4.0.2 the version, as in the archive's name +; /DNumericVersion=4.0.2 its leading numbers, for the setup program's file version +; /DSourceDir=C:\...\ironruby-4.0.2-win-x64 the unpacked archive + +#ifndef AppVersion + #error Pass /DAppVersion=, as Util/installer/build-installer.sh does +#endif +#ifndef SourceDir + #error Pass /DSourceDir=, as Util/installer/build-installer.sh does +#endif +#ifndef NumericVersion + #define NumericVersion "0.0.0" +#endif + +; "40" for 4.0.x, as in RubyInstaller's C:\Ruby40-x64: one install per Ruby series, +; side by side with the others, upgraded in place within it. +#define VersionMajor Copy(AppVersion, 1, Pos(".", AppVersion) - 1) +#define AfterMajor Copy(AppVersion, Pos(".", AppVersion) + 1) +#define VersionMinor Copy(AfterMajor, 1, Pos(".", AfterMajor) - 1) +#define Series VersionMajor + VersionMinor + +[Setup] +AppId=IronRuby{#Series}-x64 +AppName=IronRuby {#VersionMajor}.{#VersionMinor} (x64) +AppVersion={#AppVersion} +AppVerName=IronRuby {#AppVersion} (x64) +AppPublisher=IronRuby +AppPublisherURL=https://github.com/Largo/ironruby +AppSupportURL=https://github.com/Largo/ironruby/issues +AppUpdatesURL=https://github.com/Largo/ironruby/releases +VersionInfoVersion={#NumericVersion} +VersionInfoProductTextVersion={#AppVersion} +DefaultDirName={sd}\IronRuby{#Series}-x64 +DefaultGroupName=IronRuby {#VersionMajor}.{#VersionMinor} (x64) +DisableProgramGroupPage=yes +; Install for me only (the default, no admin rights) or for all users: the dialog asks, +; and /CURRENTUSER or /ALLUSERS answers it on the command line. +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog commandline +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +LicenseFile={#SourceDir}\License\License.Rtf +ChangesEnvironment=yes +ChangesAssociations=yes +UninstallDisplayName=IronRuby {#AppVersion} (x64) +OutputBaseFilename=ironruby-{#AppVersion}-win-x64-setup +Compression=lzma2/max +SolidCompression=yes +LZMANumBlockThreads=4 +WizardStyle=modern +SetupLogging=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "modifypath"; Description: "Add IronRuby to the &PATH: ironruby, ir, irb, igem" +Name: "assocfiles"; Description: "Run .&rb files with IronRuby when they are opened"; Flags: unchecked + +[Files] +; The archive's tree, unchanged. +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\Interactive IronRuby"; Filename: "{app}\irb.cmd"; WorkingDir: "{%USERPROFILE}" +Name: "{group}\Start Command Prompt with IronRuby"; Filename: "{cmd}"; Parameters: "/k ""set ""PATH={app};%PATH%"" & ironruby.cmd -v"""; WorkingDir: "{%USERPROFILE}" +Name: "{group}\IronRuby Notepad"; Filename: "{app}\Samples\Notepad\notepad.cmd"; WorkingDir: "{app}\Samples\Notepad" + +[Registry] +Root: HKA; Subkey: "Software\Classes\IronRuby.File"; ValueType: string; ValueData: "Ruby program"; Flags: uninsdeletekey; Tasks: assocfiles +Root: HKA; Subkey: "Software\Classes\IronRuby.File\shell\open\command"; ValueType: string; ValueData: """{app}\ironruby.cmd"" ""%1"" %*"; Tasks: assocfiles +; .rb itself is set in [Code], AssociateRb, which keeps the program it had before. +Root: HKA; Subkey: "Software\Classes\.rb\OpenWithProgids"; ValueType: string; ValueName: "IronRuby.File"; ValueData: ""; Flags: uninsdeletevalue; Tasks: assocfiles + +[UninstallDelete] +; Everything under Src is IronRuby's, also what it wrote there after the install: the +; gems igem installed into Src\StdLib\ruby\gems and their binstubs next to ir.exe. +; Setup refuses a directory that already holds other files, so this is never someone +; else's Src. +Type: filesandordirs; Name: "{app}\Src" +Type: filesandordirs; Name: "{app}\Samples" +Type: filesandordirs; Name: "{app}\License" + +[Code] +const + SystemEnvironmentKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + UserEnvironmentKey = 'Environment'; + +{ The PATH of the user installing, or the system's when installing for all users. } +function EnvironmentRoot: Integer; +begin + if IsAdminInstallMode then + Result := HKEY_LOCAL_MACHINE + else + Result := HKEY_CURRENT_USER; +end; + +function EnvironmentKey: String; +begin + if IsAdminInstallMode then + Result := SystemEnvironmentKey + else + Result := UserEnvironmentKey; +end; + +function SameDir(Entry, Dir: String): Boolean; +begin + Result := CompareText(RemoveBackslashUnlessRoot(Trim(Entry)), RemoveBackslashUnlessRoot(Dir)) = 0; +end; + +{ Path without its entries for Dir; every other entry, empty ones too, stays as it was. } +function PathWithout(Path, Dir: String): String; +var + Rest, Entry: String; + Separator: Integer; + First, Last: Boolean; +begin + Result := ''; + Rest := Path; + First := True; + repeat + Separator := Pos(';', Rest); + Last := Separator = 0; + if Last then + Entry := Rest + else + begin + Entry := Copy(Rest, 1, Separator - 1); + Rest := Copy(Rest, Separator + 1, Length(Rest)); + end; + if (Trim(Entry) = '') or not SameDir(Entry, Dir) then + begin + if not First then + Result := Result + ';'; + Result := Result + Entry; + First := False; + end; + until Last; +end; + +{ Read and written as REG_EXPAND_SZ, so entries such as %USERPROFILE%\bin stay unexpanded. } +function ReadPath: String; +begin + if not RegQueryStringValue(EnvironmentRoot, EnvironmentKey, 'Path', Result) then + Result := ''; +end; + +procedure WritePath(Path: String); +begin + if not RegWriteExpandStringValue(EnvironmentRoot, EnvironmentKey, 'Path', Path) then + SuppressibleMsgBox('Setup could not change the PATH in ' + EnvironmentKey + '.', mbError, MB_OK, IDOK) + else + Log('PATH is now: ' + Path); +end; + +{ First, as RubyInstaller puts Ruby first: irb is IronRuby's irb then. } +procedure AddToPath(Dir: String); +var + Path: String; +begin + Path := PathWithout(ReadPath, Dir); + if Path = '' then + WritePath(Dir) + else + WritePath(Dir + ';' + Path); +end; + +procedure RemoveFromPath(Dir: String); +var + Path, Rest: String; +begin + Path := ReadPath; + Rest := PathWithout(Path, Dir); + if Rest <> Path then + WritePath(Rest); +end; + +function DirIsEmpty(Dir: String): Boolean; +var + Find: TFindRec; +begin + Result := True; + if FindFirst(AddBackslash(Dir) + '*', Find) then + try + repeat + if (Find.Name <> '.') and (Find.Name <> '..') then + begin + Result := False; + Exit; + end; + until not FindNext(Find); + finally + FindClose(Find); + end; +end; + +{ A new or empty directory, or an IronRuby installed there before: the uninstaller removes + Src, Samples and License from it whole. And a warning for a path with a space in it. } +function NextButtonClick(CurPageID: Integer): Boolean; +var + Dir: String; +begin + Result := True; + if CurPageID = wpSelectDir then + begin + Dir := WizardDirValue; + if DirExists(Dir) and not DirIsEmpty(Dir) and not FileExists(AddBackslash(Dir) + 'ironruby.cmd') then + begin + SuppressibleMsgBox(Dir + ' already holds other files. Choose a new or empty directory for IronRuby.', + mbError, MB_OK, IDOK); + Result := False; + end + else if Pos(' ', Dir) > 0 then + Result := SuppressibleMsgBox(Dir + ' has a space in it. Bundler (its RUBYOPT), the Rails templates and mspec ' + + 'do not work from such a path. Install there anyway?', mbConfirmation, MB_YESNO, IDYES) = IDYES; + end; +end; + +const + RbKey = 'Software\Classes\.rb'; + ProgIdKey = 'Software\Classes\IronRuby.File'; + +{ .rb to IronRuby.File. The program .rb had, such as RubyInstaller's RubyFile, is kept in + IronRuby.File's PreviousProgId, for the uninstaller to give .rb back to. } +procedure AssociateRb; +var + Previous: String; +begin + if RegQueryStringValue(HKA, RbKey, '', Previous) and (Previous <> '') and (Previous <> 'IronRuby.File') then + RegWriteStringValue(HKA, ProgIdKey, 'PreviousProgId', Previous); + RegWriteStringValue(HKA, RbKey, '', 'IronRuby.File'); +end; + +{ Unless another program has taken .rb over since. } +procedure UnassociateRb; +var + Current, Previous: String; +begin + if RegQueryStringValue(HKA, RbKey, '', Current) and (Current = 'IronRuby.File') then + begin + if RegQueryStringValue(HKA, ProgIdKey, 'PreviousProgId', Previous) then + RegWriteStringValue(HKA, RbKey, '', Previous) + else + RegDeleteValue(HKA, RbKey, ''); + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + if WizardIsTaskSelected('modifypath') then + AddToPath(ExpandConstant('{app}')); + if WizardIsTaskSelected('assocfiles') then + AssociateRb; + end; +end; + +{ Before the uninstaller deletes IronRuby.File, which holds PreviousProgId. } +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then + begin + RemoveFromPath(ExpandConstant('{app}')); + UnassociateRb; + end; +end; diff --git a/Util/installer/test-installer.ps1 b/Util/installer/test-installer.ps1 new file mode 100644 index 000000000..17e840a66 --- /dev/null +++ b/Util/installer/test-installer.ps1 @@ -0,0 +1,112 @@ +# Installs IronRuby with the installer build-installer.sh made, checks it from a fresh +# environment, as a new terminal would see it, and uninstalls it again: +# +# powershell -File Util\installer\test-installer.ps1 dist\ironruby-4.0.2-win-x64-setup.exe +# powershell -File Util\installer\test-installer.ps1 SETUP.exe -AllUsers # as admin +# +# It installs silently into the default directory, C:\IronRuby40-x64, which must not +# exist yet. Then the install directory must be first on the user's PATH (the system's +# with -AllUsers), and from there `ironruby -v`, `ir`, `irb` and `igem install rack` must +# work. The uninstaller must remove the directory, gems included, and leave PATH exactly +# as it was before. Needs rubygems.org for rack. +param( + [Parameter(Mandatory = $true)][string]$Installer, + [switch]$AllUsers, + [string]$Dir = "$env:SystemDrive\IronRuby40-x64" +) +$ErrorActionPreference = 'Stop' + +function Fail([string]$message) { + Write-Host "test-installer: FAIL: $message" + exit 1 +} + +if ($AllUsers) { + $scope = 'Machine' + $environmentKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $mode = '/ALLUSERS' +} else { + $scope = 'User' + $environmentKey = 'HKCU:\Environment' + $mode = '/CURRENTUSER' +} + +# PATH as the registry holds it, %VARIABLES% unexpanded. +function Get-RawPath { + $key = Get-Item $environmentKey + $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') +} + +# The PATH a terminal opened now would get. +function Get-FreshPath { + [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') +} + +# Runs a command line in cmd.exe with the fresh PATH, as typed at a new prompt, in the +# user's home: cmd looks in the current directory first, and a checkout has launchers of +# the same names. Without quotes in it: PowerShell 7 and Windows PowerShell pass them on +# to cmd differently. +function Invoke-Fresh([string]$commandLine, [string]$stdin = $null) { + $saved = $env:Path + # Not Stop: Windows PowerShell turns a native command's redirected stderr into errors. + $ErrorActionPreference = 'Continue' + Push-Location $env:USERPROFILE + try { + $env:Path = Get-FreshPath + if ($stdin) { $output = $stdin | cmd /d /c $commandLine 2>&1 } else { $output = cmd /d /c $commandLine 2>&1 } + $code = $LASTEXITCODE + } finally { + $env:Path = $saved + Pop-Location + } + $text = ($output | Out-String).Trim() + Write-Host "> $commandLine" + Write-Host $text + if ($code -ne 0) { Fail "$commandLine exited with $code" } + $text +} + +if (Test-Path $Dir) { Fail "$Dir exists already; uninstall IronRuby from there first" } +$installer = (Resolve-Path $Installer).Path +$log = Join-Path (Split-Path $installer) 'install.log' +$pathBefore = Get-RawPath + +Write-Host "test-installer: installing $installer ($mode)" +$setup = Start-Process -FilePath $installer -Wait -PassThru -ArgumentList @( + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-', $mode, "/LOG=`"$log`"") +if ($setup.ExitCode -ne 0) { Get-Content $log -Tail 30; Fail "setup exited with $($setup.ExitCode)" } + +foreach ($launcher in 'ironruby.cmd', 'ir.cmd', 'irb.cmd', 'igem.cmd', 'unins000.exe') { + if (-not (Test-Path (Join-Path $Dir $launcher))) { Fail "no $launcher in $Dir" } +} +$first = ((Get-RawPath) -split ';')[0] +if ($first -ne $Dir) { Fail "$Dir is not first on the $scope PATH, $first is" } + +$description = Invoke-Fresh 'ironruby -v' +if ($description -notmatch '^ironruby 4\.') { Fail "ironruby -v: $description" } +$where = Invoke-Fresh 'where ironruby' +if (($where -split "`r?`n")[0] -ne "$Dir\ironruby.cmd") { Fail "ironruby is not $Dir\ironruby.cmd" } +$engine = Invoke-Fresh 'ir -e puts(RUBY_ENGINE)' +if ($engine -ne 'ironruby') { Fail "ir: $engine" } +$irb = Invoke-Fresh 'irb' "puts 6 * 7`n" +if ($irb -notmatch '42') { Fail 'irb did not run the line it was given' } +Invoke-Fresh 'igem install rack --no-document' | Out-Null +$gems = Join-Path $Dir 'Src\StdLib\ruby\gems\4.0.0\gems' +if (-not (Get-ChildItem $gems -Filter 'rack-*' -Directory)) { Fail "rack is not in $gems" } +$rack = Invoke-Fresh 'ironruby -rrack -e puts(Rack.release)' +if ($rack -notmatch '^\d') { Fail "require 'rack': $rack" } + +Write-Host 'test-installer: uninstalling' +$uninstall = Start-Process -FilePath (Join-Path $Dir 'unins000.exe') -Wait -PassThru -ArgumentList @( + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') +if ($uninstall.ExitCode -ne 0) { Fail "the uninstaller exited with $($uninstall.ExitCode)" } +# The uninstaller runs on from a copy of itself, which removes the directory last. +for ($i = 0; $i -lt 120 -and (Test-Path $Dir); $i++) { Start-Sleep -Seconds 1 } +if (Test-Path $Dir) { + Get-ChildItem $Dir -Recurse | Select-Object -First 20 | ForEach-Object { Write-Host " left: $($_.FullName)" } + Fail "$Dir is still there" +} +$pathAfter = Get-RawPath +if ($pathAfter -ne $pathBefore) { Fail "the $scope PATH changed: was`n $pathBefore`nis`n $pathAfter" } +Write-Host "test-installer: OK: installed, ran, installed rack, and uninstalled without a trace" +exit 0 From bca6dc63b0fb1f520307e86b5add5c071fb3fea1 Mon Sep 17 00:00:00 2001 From: Largo Date: Wed, 28 Oct 2026 15:42:02 +0100 Subject: [PATCH 2/3] irb on Windows: Fiddle finds a Win32 function's A export, reline passes Strings irb in the 4.0.2 Windows build stopped at once: reline/io/windows.rb:112:in 'Handle#sym': unknown symbol "FillConsoleOutputCharacter" reline binds the console API through Fiddle by the functions' bare names, and kernel32 exports only FillConsoleOutputCharacterA and ...W (ScrollConsoleScreenBuffer likewise). CRuby's fiddle looks a missing name up again with "A" appended on Windows; Handle#sym and #sym_defined? now do too. (CRuby also tries 32-bit stdcall decorations, name@N, which x64 does not have.) Past that, irb in a console crashed with an access violation: reline's Win32API#call passes each pointer argument as [str].pack("p").unpack("q*"), and IronRuby's pack("p") writes an index into the packed string's list, not an address, so Windows wrote through address 1. The vendored reline now hands Fiddle the String itself, which Fiddle passes as a pointer to its bytes and copies back what C wrote - the buffer CRuby's address names. irb now reaches its prompt in a Windows console, and runs piped input; the installer test in release.yml runs irb from the installed tree. --- Docs/releases/v4.0.3.md | 8 ++++++++ Src/Libraries/Fiddle/FiddleHandle.cs | 14 ++++++++++++++ Src/StdLib/ruby/4.0/reline/io/windows.rb | 5 ++++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Docs/releases/v4.0.3.md b/Docs/releases/v4.0.3.md index 9553069ca..c048778b3 100644 --- a/Docs/releases/v4.0.3.md +++ b/Docs/releases/v4.0.3.md @@ -30,3 +30,11 @@ gh attestation verify ironruby-4.0.3-win-x64-setup.exe --repo Largo/ironruby the files, the gems installed since, and the PATH entry. It is built in CI with Inno Setup 6.7.3 from `Util/installer/ironruby.iss`, and installed, run and uninstalled there before it is published. + +### Fixes + +- **irb starts on Windows again.** In 4.0.2 it stopped at once with `unknown symbol + "FillConsoleOutputCharacter"`. reline asks Fiddle for Win32 functions by their bare + names, and Fiddle now also looks for the `A` export, as CRuby's does. reline also passed + its buffers to the console API as `pack("p")` addresses, which on IronRuby are no + addresses; it now hands Fiddle the Strings themselves. diff --git a/Src/Libraries/Fiddle/FiddleHandle.cs b/Src/Libraries/Fiddle/FiddleHandle.cs index ebaf371a1..0d6eada4b 100644 --- a/Src/Libraries/Fiddle/FiddleHandle.cs +++ b/Src/Libraries/Fiddle/FiddleHandle.cs @@ -226,6 +226,12 @@ public static object Initialize(BlockParam block, Handle/*!*/ self, [DefaultPara #region Symbols + /// + /// On Windows a name that is not exported is looked up again with "A" appended, as + /// CRuby's fiddle does: the Win32 API exports FillConsoleOutputCharacterA and + /// FillConsoleOutputCharacterW, not FillConsoleOutputCharacter, and reline asks for + /// the bare name. (CRuby also tries 32-bit stdcall decorations, name@N; x64 has none.) + /// private IntPtr Lookup(string/*!*/ name) { if (!_open) { throw new DLError("closed handle"); @@ -233,6 +239,14 @@ private IntPtr Lookup(string/*!*/ name) { if (_next) { throw new DLError("RTLD_NEXT is not supported on this platform"); } + IntPtr address = Export(name); + if (address == IntPtr.Zero && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + address = Export(name + "A"); + } + return address; + } + + private IntPtr Export(string/*!*/ name) { IntPtr address; try { if (!NativeLibrary.TryGetExport(_address, name, out address)) { diff --git a/Src/StdLib/ruby/4.0/reline/io/windows.rb b/Src/StdLib/ruby/4.0/reline/io/windows.rb index 5c1ab6d08..568161b95 100644 --- a/Src/StdLib/ruby/4.0/reline/io/windows.rb +++ b/Src/StdLib/ruby/4.0/reline/io/windows.rb @@ -117,7 +117,10 @@ def initialize(dllname, func, import, export = "0", calltype = :stdcall) def call(*args) import = @proto.split("") args.each_with_index do |x, i| - args[i], = [x == 0 ? nil : +x].pack("p").unpack(POINTER_TYPE) if import[i] == "S" + # IronRuby: pack("p") holds no real address (a String has none on .NET's heap), + # so the String goes to Fiddle itself, which hands C its bytes and copies back + # what C wrote into them - the buffer CRuby's address points at. + args[i] = (x == 0 ? nil : +x) if import[i] == "S" args[i], = [x].pack("I").unpack("i") if import[i] == "I" end ret, = @func.call(*args) From 920e6ca88a2c9cd88fdd414158218f5d6e297b24 Mon Sep 17 00:00:00 2001 From: Largo Date: Wed, 28 Oct 2026 15:45:35 +0100 Subject: [PATCH 3/3] Fiddle looks a Win32 name up as CRuby's does; irb checked in CI and in packaging Handle#sym and #sym_defined? on Windows now try a name in the order of fiddle 1.1.8's fiddle_handle_find_func: as given, with the "A" suffix, then with the stdcall decorations "@0" to "@252", plain and after the "A". The previous commit tried only the "A". Linux and macOS are unchanged. - Fiddle_AnsiSuffix1 (MiscTests): GetModuleHandle is GetModuleHandleA, FillConsoleOutputCharacter is found, and an unknown name gives CRuby's message. - ci.yml's console smoke test and package-release.sh's check of the unpacked tree pipe `puts 6 * 7` into irb and want 42, on Windows and on Linux: irb died at a pipe as at a console in 4.0.2, so both catch that. Written in the session that found the bug alongside this one, and taken over here. --- .github/workflows/ci.yml | 4 ++++ Src/IronRuby.Tests/RubyTests.cs | 1 + Src/IronRuby.Tests/Runtime/MiscTests.cs | 28 ++++++++++++++++++++++ Src/Libraries/Fiddle/FiddleHandle.cs | 32 +++++++++++++++---------- Util/package-release.sh | 5 +++- 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec83e605c..cd5c484d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,6 +219,9 @@ jobs: cmd //c ironruby.cmd smoke.rb cmd //c ironruby.cmd names.rb cmd //c ir.cmd -e "puts RUBY_ENGINE" + # irb loads reline, which on Windows looks up Win32 functions through Fiddle by + # their names without the A suffix; 4.0.2 died there, at a pipe as at a console. + printf 'puts 6 * 7\n' | cmd //c irb.cmd | grep -x 42 cmd //c ironruby.cmd Util/windows-desktop-check.rb rubyw="Src/Console/bin/Debug/$TFM/ironrubyw.exe" ruby subsystem.rb "$rubyw" @@ -227,6 +230,7 @@ jobs: ./ironruby smoke.rb ./ironruby names.rb ./ir.sh -e 'puts RUBY_ENGINE' + printf 'puts 6 * 7\n' | ./irb.sh | grep -x 42 fi # IO#write_nonblock on a socket claims only what the kernel took (#14). On Linux the diff --git a/Src/IronRuby.Tests/RubyTests.cs b/Src/IronRuby.Tests/RubyTests.cs index 162c4ad37..eecf55be9 100644 --- a/Src/IronRuby.Tests/RubyTests.cs +++ b/Src/IronRuby.Tests/RubyTests.cs @@ -730,6 +730,7 @@ public Tests(Driver/*!*/ driver) { Zlib_Streams1, Process_SpawnKill1, File_DriveLetters1, + Fiddle_AnsiSuffix1, Dir_ChdirRelative1, File_ExpandTildeOrder1, File_FlockReadpartialRlimit1, diff --git a/Src/IronRuby.Tests/Runtime/MiscTests.cs b/Src/IronRuby.Tests/Runtime/MiscTests.cs index e41885611..adca71f71 100644 --- a/Src/IronRuby.Tests/Runtime/MiscTests.cs +++ b/Src/IronRuby.Tests/Runtime/MiscTests.cs @@ -945,6 +945,34 @@ p Pathname.new('C:/x/y').absolute? == windows, Pathname.new('/x').absolute? "); } + /// + /// Fiddle::Handle finds a Win32 function by the name without its A/W suffix, as CRuby's + /// fiddle does (it tries "A" next): kernel32 exports FillConsoleOutputCharacterA and ...W + /// but no FillConsoleOutputCharacter, and reline asks for that one - irb died at startup + /// on Windows without it. Elsewhere there is no suffix to try; the answers are the same. + /// + public void Fiddle_AnsiSuffix1() { + TestOutput(@" +require 'fiddle' +if File::ALT_SEPARATOR == '\\' + k = Fiddle::Handle.new('kernel32') + p k['GetModuleHandle'] == k['GetModuleHandleA'], !k.sym_defined?('FillConsoleOutputCharacter').nil? + begin + k['NoSuchFunction'] + rescue Fiddle::DLError => e + p e.message + end +else + p true, true + p 'unknown symbol ""NoSuchFunction""' +end +", @" +true +true +""unknown symbol \""NoSuchFunction\"""" +"); + } + /// /// A relative Dir.chdir is resolved once, from the directory in force - Dir.pwd keeping /// 8.3 short names once made it twice, so ".." went up two levels. diff --git a/Src/Libraries/Fiddle/FiddleHandle.cs b/Src/Libraries/Fiddle/FiddleHandle.cs index 0d6eada4b..1944075db 100644 --- a/Src/Libraries/Fiddle/FiddleHandle.cs +++ b/Src/Libraries/Fiddle/FiddleHandle.cs @@ -227,10 +227,11 @@ public static object Initialize(BlockParam block, Handle/*!*/ self, [DefaultPara #region Symbols /// - /// On Windows a name that is not exported is looked up again with "A" appended, as - /// CRuby's fiddle does: the Win32 API exports FillConsoleOutputCharacterA and - /// FillConsoleOutputCharacterW, not FillConsoleOutputCharacter, and reline asks for - /// the bare name. (CRuby also tries 32-bit stdcall decorations, name@N; x64 has none.) + /// The address of an export, or zero. On Windows a name is tried as CRuby's fiddle + /// tries it (fiddle_handle_find_func): as given, then with the ANSI "A" suffix - the + /// Win32 API exports FillConsoleOutputCharacterA and ...W but no + /// FillConsoleOutputCharacter, which reline asks for - then with the stdcall + /// decorations "@0" to "@252", plain and after the "A". /// private IntPtr Lookup(string/*!*/ name) { if (!_open) { @@ -240,22 +241,29 @@ private IntPtr Lookup(string/*!*/ name) { throw new DLError("RTLD_NEXT is not supported on this platform"); } IntPtr address = Export(name); - if (address == IntPtr.Zero && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - address = Export(name + "A"); + if (address != IntPtr.Zero || !OperatingSystem.IsWindows()) { + return address; } - return address; + if ((address = Export(name + "A")) != IntPtr.Zero) { + return address; + } + foreach (string stem in new[] { name, name + "A" }) { + for (int i = 0; i < 256; i += 4) { + if ((address = Export(stem + "@" + i.ToString(System.Globalization.CultureInfo.InvariantCulture))) != IntPtr.Zero) { + return address; + } + } + } + return IntPtr.Zero; } private IntPtr Export(string/*!*/ name) { - IntPtr address; try { - if (!NativeLibrary.TryGetExport(_address, name, out address)) { - return IntPtr.Zero; - } + IntPtr address; + return NativeLibrary.TryGetExport(_address, name, out address) ? address : IntPtr.Zero; } catch (Exception) { return IntPtr.Zero; } - return address; } [RubyMethod("sym")] diff --git a/Util/package-release.sh b/Util/package-release.sh index c81b0a5f0..a119b3ae7 100755 --- a/Util/package-release.sh +++ b/Util/package-release.sh @@ -110,7 +110,8 @@ esac # A quick check that the unpacked tree starts, before it is archived - and on Windows # that Windows Forms and WPF load from it, which a missing framework reference breaks -# without breaking anything else. +# without breaking anything else. irb has to start too: it loads reline, which on Windows +# looks up Win32 functions through Fiddle, and 4.0.2's died there. # One command per line: set -e does not stop at a failure on the left of && - nor at a # case whose last && list failed that way - so a chain would let a broken tree through. case "$RID" in @@ -119,11 +120,13 @@ case "$RID" in (cd "$STAGE" cmd //c ironruby.cmd -e 'puts RUBY_DESCRIPTION' cmd //c ir.cmd -e 'puts RUBY_ENGINE' + printf 'puts 6 * 7\n' | cmd //c irb.cmd | grep -x 42 cmd //c ir.cmd "$(cygpath -w "$IR_ROOT/Util/windows-desktop-check.rb")") ;; *) "$STAGE/ironruby" -e 'puts RUBY_DESCRIPTION' "$STAGE/ir.sh" -e 'puts RUBY_ENGINE' + printf 'puts 6 * 7\n' | "$STAGE/irb.sh" | grep -x 42 ;; esac