From d88cc368a115d6021d0d4454cf1377e91d1ceb80 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 23 Jul 2026 15:12:35 -0400 Subject: [PATCH 1/5] Ship direct desktop installer assets for 0.6.2 --- .github/workflows/integration-tests.yml | 51 +++++++--- .github/workflows/publish.yml | 49 +++++----- docs/source/development/mcp_release.rst | 9 +- .../installation_and_setup.rst | 13 ++- openhcs/__init__.py | 2 +- .../codex/openhcs/.codex-plugin/plugin.json | 2 +- packaging/codex/openhcs/.mcp.json | 2 +- packaging/installers/README.md | 26 +++--- .../windows/Build-InstallerLauncher.ps1 | 38 ++++++-- .../installers/windows/InstallerLauncher.cs | 92 +++++++++++++++---- .../windows/InstallerLauncher.csproj | 10 +- packaging/mcpb/openhcs/manifest.json | 2 +- packaging/mcpb/openhcs/pyproject.toml | 4 +- server.json | 6 +- .../installer/test_macos_simple_installer.py | 19 ++++ .../test_windows_simple_installer.py | 51 ++++++++-- tests/unit/test_build_website.py | 8 +- website/index.html | 8 +- 18 files changed, 284 insertions(+), 108 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 5f676174c..c0be6bb94 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -504,22 +504,21 @@ jobs: $releaseVersion = $releaseVersion.Trim() $stage = Join-Path $env:RUNNER_TEMP "OpenHCS-Windows-Smoke" $installRoot = Join-Path $env:RUNNER_TEMP "OpenHCS Installed" + $contractPath = Join-Path $env:RUNNER_TEMP "installer_contract.json" New-Item -ItemType Directory -Path $stage | Out-Null - Copy-Item ` - packaging/installers/windows/Install-OpenHCS.ps1 ` - -Destination $stage + python scripts/render_installer_contract.py ` + --version $releaseVersion ` + --output $contractPath & packaging/installers/windows/Build-InstallerLauncher.ps1 ` - -OutputDirectory $stage + -OutputDirectory $stage ` + -ContractPath $contractPath if ($LASTEXITCODE -ne 0) { throw "Windows GUI installer launcher build failed." } - python scripts/render_installer_contract.py ` - --version $releaseVersion ` - --output (Join-Path $stage "installer_contract.json") - $launcher = Join-Path $stage "Install-OpenHCS.exe" + $launcher = Join-Path $stage "OpenHCS-Windows-Installer.exe" if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { - throw "Windows GUI installer launcher was not staged." + throw "Single-file Windows GUI installer was not staged." } $subsystem = python -c "import struct,sys; from pathlib import Path; data=Path(sys.argv[1]).read_bytes(); pe=struct.unpack_from('`_, extract it, -and open the included installer. Re-running the same installer updates the +Download the installer for your operating system from the matching +`GitHub release `_. On Windows, +download and run ``OpenHCS-Windows-Installer.exe``. On macOS, open +``OpenHCS-macOS-Installer.dmg`` and then open ``OpenHCS Installer``. Neither +platform requires ZIP extraction. Re-running the same installer updates the isolated environment. Installation details and failures are retained in the OpenHCS user log directory shown by the installer. The initial bootstrap installers are not code-signed or notarized. Windows SmartScreen or macOS Gatekeeper may therefore ask you to confirm that you trust -the downloaded release. The release archive is only the bootstrap interface; -Python and OpenHCS remain managed by uv and PyPI in the dedicated environment. +the downloaded release. The downloaded installer is only the bootstrap +interface; Python and OpenHCS remain managed by uv and PyPI in the dedicated +environment. Manual installation ------------------- diff --git a/openhcs/__init__.py b/openhcs/__init__.py index fa0323171..fcb203f2c 100644 --- a/openhcs/__init__.py +++ b/openhcs/__init__.py @@ -14,7 +14,7 @@ from openhcs._source_dependencies import ensure_source_checkout_external_paths -__version__ = "0.6.1" +__version__ = "0.6.2" # Configure polystore defaults for OpenHCS integration os.environ.setdefault("POLYSTORE_METADATA_FILENAME", "openhcs_metadata.json") diff --git a/packaging/codex/openhcs/.codex-plugin/plugin.json b/packaging/codex/openhcs/.codex-plugin/plugin.json index f26d91aca..2ae3afd23 100644 --- a/packaging/codex/openhcs/.codex-plugin/plugin.json +++ b/packaging/codex/openhcs/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "openhcs", - "version": "0.6.1", + "version": "0.6.2", "description": "Inspect, author, validate, and run OpenHCS microscopy workflows through the local OpenHCS MCP server.", "author": { "name": "OpenHCSDev", diff --git a/packaging/codex/openhcs/.mcp.json b/packaging/codex/openhcs/.mcp.json index 7f1107a2f..785a9ff83 100644 --- a/packaging/codex/openhcs/.mcp.json +++ b/packaging/codex/openhcs/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "openhcs[gui,mcp]==0.6.1", + "openhcs[gui,mcp]==0.6.2", "openhcs-mcp" ] } diff --git a/packaging/installers/README.md b/packaging/installers/README.md index 53c88b9d7..319df07b4 100644 --- a/packaging/installers/README.md +++ b/packaging/installers/README.md @@ -36,9 +36,10 @@ entry point remain authoritative. ## What users see -The release archives are intended for users who do not want to work in a -terminal. After extracting the downloaded archive, installation stays inside a -small native window: +The release assets are intended for users who do not want to work in a +terminal. No archive extraction is required: Windows users run one executable, +and macOS users open one disk image. Installation stays inside a small native +window: - Windows presents Welcome, installation-folder, progress, and Finish pages. The final page can launch OpenHCS immediately. @@ -56,7 +57,7 @@ From the repository environment: ```bash python -m pytest -q tests/installer python scripts/render_installer_contract.py \ - --version 0.6.1 \ + --version 0.6.2 \ --output /tmp/openhcs-installer-contract.json ``` @@ -66,14 +67,15 @@ each adapter. ## Release assets Tag publication renders a contract pinned to the tag version and attaches two -archives to the GitHub release: +directly usable files to the GitHub release: -- `OpenHCS-Windows-Installer.zip` contains a small GUI-subsystem - `Install-OpenHCS.exe`, its internal PowerShell worker, and the pinned - contract. Extract the archive and double-click `Install-OpenHCS.exe`. -- `OpenHCS-macOS-Installer.zip` contains a compiled `OpenHCS Installer.app` - with the bootstrap and pinned contract embedded as application resources. - Extract the archive and double-click the application. +- `OpenHCS-Windows-Installer.exe` is a small GUI-subsystem executable with its + PowerShell worker and pinned contract embedded. Double-click the downloaded + file. +- `OpenHCS-macOS-Installer.dmg` contains the compiled + `OpenHCS Installer.app`, whose bootstrap and pinned contract are embedded as + application resources. Open the downloaded disk image, then open the + application. Pull-request CI parses the Windows PowerShell source and compiles the GUI-subsystem launcher on Windows, and compiles the universal Swift/AppKit @@ -85,7 +87,7 @@ MCP generates a two-channel synthetic plate, the packaged neurite preset runs through the real execution server, Napari receives the result, MCP validates mounted nonzero viewer payloads, and the smoke shuts down only its dynamically allocated TCP runtime/viewer endpoints. The tag workflow repeats the source -gates before making either archive a release asset. +gates before making either file a release asset. Users can run the same portable acceptance after installation: diff --git a/packaging/installers/windows/Build-InstallerLauncher.ps1 b/packaging/installers/windows/Build-InstallerLauncher.ps1 index 2ed700297..47009a797 100644 --- a/packaging/installers/windows/Build-InstallerLauncher.ps1 +++ b/packaging/installers/windows/Build-InstallerLauncher.ps1 @@ -1,7 +1,9 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$OutputDirectory + [string]$OutputDirectory, + [Parameter(Mandatory = $true)] + [string]$ContractPath ) Set-StrictMode -Version Latest @@ -9,19 +11,36 @@ $ErrorActionPreference = "Stop" $resolvedOutput = [IO.Path]::GetFullPath($OutputDirectory) [IO.Directory]::CreateDirectory($resolvedOutput) | Out-Null - -$projectPath = [IO.Path]::Combine( - $PSScriptRoot, - "InstallerLauncher.csproj" -) +$resolvedContract = [IO.Path]::GetFullPath($ContractPath) +if (-not (Test-Path -LiteralPath $resolvedContract -PathType Leaf)) { + throw "Rendered installer contract not found: $resolvedContract" +} $temporaryRoot = [IO.Path]::Combine( [IO.Path]::GetTempPath(), "openhcs-installer-launcher-$([Guid]::NewGuid().ToString('N'))" ) +$sourceRoot = [IO.Path]::Combine($temporaryRoot, "source") +$windowsSourceRoot = [IO.Path]::Combine($sourceRoot, "windows") $buildRoot = [IO.Path]::Combine($temporaryRoot, "build") $artifactsRoot = [IO.Path]::Combine($temporaryRoot, "artifacts") try { + [IO.Directory]::CreateDirectory($windowsSourceRoot) | Out-Null + foreach ($sourceName in @( + "InstallerLauncher.cs", + "InstallerLauncher.csproj", + "Install-OpenHCS.ps1" + )) { + Copy-Item -LiteralPath ([IO.Path]::Combine($PSScriptRoot, $sourceName)) ` + -Destination ([IO.Path]::Combine($windowsSourceRoot, $sourceName)) + } + Copy-Item -LiteralPath $resolvedContract -Destination ( + [IO.Path]::Combine($sourceRoot, "installer_contract.json") + ) + $projectPath = [IO.Path]::Combine( + $windowsSourceRoot, + "InstallerLauncher.csproj" + ) & dotnet build $projectPath ` --configuration Release ` --output $buildRoot ` @@ -31,12 +50,13 @@ try { throw "The Windows installer launcher build failed with exit code $LASTEXITCODE." } - $launcherPath = [IO.Path]::Combine($buildRoot, "Install-OpenHCS.exe") + $launcherName = "OpenHCS-Windows-Installer.exe" + $launcherPath = [IO.Path]::Combine($buildRoot, $launcherName) if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { - throw "The Windows installer launcher build did not produce Install-OpenHCS.exe." + throw "The Windows installer build did not produce $launcherName." } Copy-Item -LiteralPath $launcherPath -Destination ( - [IO.Path]::Combine($resolvedOutput, "Install-OpenHCS.exe") + [IO.Path]::Combine($resolvedOutput, $launcherName) ) -Force } finally { diff --git a/packaging/installers/windows/InstallerLauncher.cs b/packaging/installers/windows/InstallerLauncher.cs index e084fe1bc..1cdf7dddc 100644 --- a/packaging/installers/windows/InstallerLauncher.cs +++ b/packaging/installers/windows/InstallerLauncher.cs @@ -2,12 +2,17 @@ using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; +using System.Reflection; using System.Text; internal static class InstallerLauncher { private const uint MessageBoxOk = 0x00000000; private const uint MessageBoxIconError = 0x00000010; + private const string WorkerResourceName = + "OpenHCS.Installer.Install-OpenHCS.ps1"; + private const string ContractResourceName = + "OpenHCS.Installer.installer_contract.json"; [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int MessageBoxW( @@ -18,21 +23,27 @@ uint type ); [STAThread] - private static int Main() + private static int Main(string[] arguments) { + string temporaryDirectory = null; try { - string launcherDirectory = AppContext.BaseDirectory; + temporaryDirectory = Path.Combine( + Path.GetTempPath(), + "OpenHCS Installer", + Guid.NewGuid().ToString("N") + ); + Directory.CreateDirectory(temporaryDirectory); string installerScript = Path.Combine( - launcherDirectory, + temporaryDirectory, "Install-OpenHCS.ps1" ); string installerContract = Path.Combine( - launcherDirectory, + temporaryDirectory, "installer_contract.json" ); - RequireSiblingFile(installerScript); - RequireSiblingFile(installerContract); + ExtractEmbeddedFile(WorkerResourceName, installerScript); + ExtractEmbeddedFile(ContractResourceName, installerContract); string windowsDirectory = Environment.GetFolderPath( Environment.SpecialFolder.Windows @@ -55,15 +66,21 @@ private static int Main() ProcessStartInfo startInfo = new ProcessStartInfo { FileName = powerShell, - WorkingDirectory = launcherDirectory, + WorkingDirectory = temporaryDirectory, UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }; - startInfo.Arguments = ( + StringBuilder powerShellArguments = new StringBuilder( "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File " - + QuoteWindowsArgument(installerScript) ); + powerShellArguments.Append(QuoteWindowsArgument(installerScript)); + foreach (string argument in arguments) + { + powerShellArguments.Append(' '); + powerShellArguments.Append(QuoteWindowsArgument(argument)); + } + startInfo.Arguments = powerShellArguments.ToString(); Process process = Process.Start(startInfo); if (process == null) @@ -72,8 +89,11 @@ private static int Main() "Windows could not start the setup wizard." ); } - process.Dispose(); - return 0; + using (process) + { + process.WaitForExit(); + return process.ExitCode; + } } catch (Exception exception) { @@ -85,16 +105,54 @@ private static int Main() ); return 1; } + finally + { + TryDeleteTemporaryDirectory(temporaryDirectory); + } } - private static void RequireSiblingFile(string path) + private static void ExtractEmbeddedFile(string resourceName, string outputPath) { - if (!File.Exists(path)) + Assembly assembly = Assembly.GetExecutingAssembly(); + using (Stream input = assembly.GetManifestResourceStream(resourceName)) { - throw new FileNotFoundException( - "The installer archive is incomplete. Extract all files together.", - path - ); + if (input == null) + { + throw new InvalidDataException( + "The installer is missing embedded resource " + resourceName + "." + ); + } + using ( + FileStream output = new FileStream( + outputPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None + ) + ) + { + input.CopyTo(output); + } + } + } + + private static void TryDeleteTemporaryDirectory(string path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, true); + } + } + catch + { + // A scanner can briefly retain an extracted file. The random + // user-temporary directory contains no credentials or user data. } } diff --git a/packaging/installers/windows/InstallerLauncher.csproj b/packaging/installers/windows/InstallerLauncher.csproj index 155b1b4a7..fc189776a 100644 --- a/packaging/installers/windows/InstallerLauncher.csproj +++ b/packaging/installers/windows/InstallerLauncher.csproj @@ -2,8 +2,16 @@ WinExe net48 - Install-OpenHCS + OpenHCS-Windows-Installer OpenHCS.Installer latest + + + OpenHCS.Installer.Install-OpenHCS.ps1 + + + OpenHCS.Installer.installer_contract.json + + diff --git a/packaging/mcpb/openhcs/manifest.json b/packaging/mcpb/openhcs/manifest.json index 7835ae07e..fba3e43f7 100644 --- a/packaging/mcpb/openhcs/manifest.json +++ b/packaging/mcpb/openhcs/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.4", "name": "openhcs", "display_name": "OpenHCS", - "version": "0.6.1", + "version": "0.6.2", "description": "Operate local OpenHCS microscopy workflows through MCP.", "long_description": "Installs a pinned OpenHCS environment with the MCP server and PyQt user interface. The MCP process runs over stdio and attaches to an authenticated, separately running OpenHCS UI when GUI tools are requested.", "author": { diff --git a/packaging/mcpb/openhcs/pyproject.toml b/packaging/mcpb/openhcs/pyproject.toml index 190125541..42c58ba10 100644 --- a/packaging/mcpb/openhcs/pyproject.toml +++ b/packaging/mcpb/openhcs/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "openhcs-mcpb-runtime" -version = "0.6.1" +version = "0.6.2" description = "Managed runtime for the OpenHCS MCP Bundle" requires-python = ">=3.11,<3.14" dependencies = [ - "openhcs[gui,mcp]==0.6.1", + "openhcs[gui,mcp]==0.6.2", ] diff --git a/server.json b/server.json index 7af81a4f4..cc7052154 100644 --- a/server.json +++ b/server.json @@ -8,19 +8,19 @@ "url": "https://github.com/OpenHCSDev/OpenHCS", "source": "github" }, - "version": "0.6.1", + "version": "0.6.2", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "openhcs", - "version": "0.6.1", + "version": "0.6.2", "runtimeHint": "uvx", "runtimeArguments": [ { "type": "named", "name": "--with", - "value": "openhcs[gui,mcp]==0.6.1", + "value": "openhcs[gui,mcp]==0.6.2", "description": "Install the matching OpenHCS GUI and MCP dependencies." } ], diff --git a/tests/installer/test_macos_simple_installer.py b/tests/installer/test_macos_simple_installer.py index 5ccce4b20..558f9cd4c 100644 --- a/tests/installer/test_macos_simple_installer.py +++ b/tests/installer/test_macos_simple_installer.py @@ -21,6 +21,7 @@ APP_SOURCE_PATH = MACOS_ROOT / "OpenHCSInstaller.swift" BUILD_PATH = MACOS_ROOT / "build-installer.sh" CONTRACT_PATH = INSTALLER_ROOT / "installer_contract.json" +PUBLISH_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "publish.yml" def _bootstrap() -> str: @@ -184,6 +185,24 @@ def test_macos_installer_builds_a_universal_native_app_with_embedded_contract() assert "Contents/Resources/install-openhcs.sh" in build +def test_macos_release_is_one_verified_disk_image() -> None: + workflow = PUBLISH_WORKFLOW_PATH.read_text(encoding="utf-8") + macos_job = workflow[ + workflow.index(" build-macos-installer:") : workflow.index( + " build-and-publish:" + ) + ] + + assert "OpenHCS-macOS-Installer.dmg" in macos_job + assert "hdiutil create" in macos_job + assert '-volname "OpenHCS Installer"' in macos_job + assert "-format UDZO" in macos_job + assert "hdiutil verify" in macos_job + assert "path: OpenHCS-macOS-Installer.dmg" in macos_job + assert "OpenHCS-macOS-Installer.zip" not in macos_job + assert "ditto -c -k" not in macos_job + + def test_macos_app_has_responsive_welcome_progress_and_finish_states() -> None: source = APP_SOURCE_PATH.read_text(encoding="utf-8") diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index a1a3c598f..0bf66740e 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -18,6 +18,7 @@ INTEGRATION_WORKFLOW_PATH = ( REPOSITORY_ROOT / ".github" / "workflows" / "integration-tests.yml" ) +PUBLISH_WORKFLOW_PATH = REPOSITORY_ROOT / ".github" / "workflows" / "publish.yml" def _source() -> str: @@ -44,23 +45,39 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert "" not in project assert "" not in project assert "" not in project - assert "Install-OpenHCS" in project + assert "OpenHCS-Windows-Installer" in project + assert 'EmbeddedResource Include="Install-OpenHCS.ps1"' in project + assert 'EmbeddedResource Include="..\\installer_contract.json"' in project + assert "OpenHCS.Installer.Install-OpenHCS.ps1" in project + assert "OpenHCS.Installer.installer_contract.json" in project assert "dotnet build $projectPath" in build assert "--runtime" not in build assert "--self-contained" not in build - assert '"Install-OpenHCS.exe"' in build - - assert "AppContext.BaseDirectory" in launcher + assert "[string]$ContractPath" in build + assert '"OpenHCS-Windows-Installer.exe"' in build + assert '"installer_contract.json"' in build + + assert "Assembly.GetExecutingAssembly()" in launcher + assert "GetManifestResourceStream(resourceName)" in launcher + assert 'Guid.NewGuid().ToString("N")' in launcher + assert "Path.GetTempPath()" in launcher assert '"Install-OpenHCS.ps1"' in launcher assert '"installer_contract.json"' in launcher - assert "RequireSiblingFile(installerScript)" in launcher - assert "RequireSiblingFile(installerContract)" in launcher + assert "AppContext.BaseDirectory" not in launcher + assert "RequireSiblingFile" not in launcher + assert "ExtractEmbeddedFile(WorkerResourceName, installerScript)" in launcher + assert "ExtractEmbeddedFile(ContractResourceName, installerContract)" in launcher assert "UseShellExecute = false" in launcher assert "CreateNoWindow = true" in launcher assert "WindowStyle = ProcessWindowStyle.Hidden" in launcher assert "startInfo.ArgumentList" not in launcher - assert "startInfo.Arguments = (" in launcher - assert "+ QuoteWindowsArgument(installerScript)" in launcher + assert ( + "powerShellArguments.Append(QuoteWindowsArgument(installerScript))" in launcher + ) + assert "foreach (string argument in arguments)" in launcher + assert "process.WaitForExit()" in launcher + assert "return process.ExitCode" in launcher + assert "TryDeleteTemporaryDirectory(temporaryDirectory)" in launcher assert "private static string QuoteWindowsArgument(string value)" in launcher assert "pendingBackslashes * 2" in launcher assert "(pendingBackslashes * 2) + 1" in launcher @@ -320,15 +337,29 @@ def test_windows_installer_ci_has_an_absolute_safety_ceiling() -> None: ] assert " timeout-minutes: 20" in smoke_step - assert "Install-OpenHCS.ps1" in smoke_step assert "Build-InstallerLauncher.ps1" in smoke_step - assert '"Install-OpenHCS.exe"' in smoke_step + assert '"OpenHCS-Windows-Installer.exe"' in smoke_step assert "GUI-subsystem executable" in smoke_step assert "Length -gt 2MB" in smoke_step assert '"openhcs-installer-cancel-{0}.marker"' in smoke_step assert "-CancellationPath $cancellationPath" in smoke_step +def test_windows_release_is_one_directly_runnable_file() -> None: + workflow = PUBLISH_WORKFLOW_PATH.read_text(encoding="utf-8") + windows_job = workflow[ + workflow.index(" build-windows-installer:") : workflow.index( + " build-macos-installer:" + ) + ] + + assert "Build release-pinned single-file Windows installer" in windows_job + assert "OpenHCS-Windows-Installer.exe" in windows_job + assert "path: OpenHCS-Windows-Installer.exe" in windows_job + assert "Compress-Archive" not in windows_job + assert "OpenHCS-Windows-Installer.zip" not in windows_job + + def test_windows_installer_ci_uses_napari_tested_software_opengl() -> None: workflow = INTEGRATION_WORKFLOW_PATH.read_text(encoding="utf-8") desktop_job = workflow[ diff --git a/tests/unit/test_build_website.py b/tests/unit/test_build_website.py index bcf1d380c..bcedde20b 100644 --- a/tests/unit/test_build_website.py +++ b/tests/unit/test_build_website.py @@ -80,9 +80,15 @@ def test_shipping_copy_projects_current_release_and_keeps_boundaries_explicit( ) assert len(installer_assets) == 2 assert all(asset_name in publish_workflow for asset_name in installer_assets) + assert installer_assets == [ + "OpenHCS-Windows-Installer.exe", + "OpenHCS-macOS-Installer.dmg", + ] + assert all(not asset_name.endswith(".zip") for asset_name in installer_assets) assert "Download for Windows" in html assert "Download for macOS" in html - assert "Extract, then run Install-OpenHCS.exe" in html + assert "Download and run — no ZIP to extract" in html + assert "Open the DMG, then open OpenHCS Installer" in html assert "Install-OpenHCS.cmd" not in html assert ( "User-scoped, CPU-only installation with CellProfiler compatibility, local MCP" diff --git a/website/index.html b/website/index.html index c0db20aaa..a104ee294 100644 --- a/website/index.html +++ b/website/index.html @@ -319,18 +319,18 @@

Install your way.

From b874122981ce1ccc54d9ffaaa0c541119047163f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 23 Jul 2026 15:16:24 -0400 Subject: [PATCH 2/5] Run the Windows installer in the native process --- packaging/installers/windows/InstallerLauncher.cs | 6 ++++++ packaging/installers/windows/InstallerLauncher.csproj | 1 + tests/installer/test_windows_simple_installer.py | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/packaging/installers/windows/InstallerLauncher.cs b/packaging/installers/windows/InstallerLauncher.cs index 1cdf7dddc..03a5518c2 100644 --- a/packaging/installers/windows/InstallerLauncher.cs +++ b/packaging/installers/windows/InstallerLauncher.cs @@ -28,6 +28,12 @@ private static int Main(string[] arguments) string temporaryDirectory = null; try { + if (Environment.Is64BitOperatingSystem && !Environment.Is64BitProcess) + { + throw new PlatformNotSupportedException( + "The OpenHCS installer must use native 64-bit Windows PowerShell." + ); + } temporaryDirectory = Path.Combine( Path.GetTempPath(), "OpenHCS Installer", diff --git a/packaging/installers/windows/InstallerLauncher.csproj b/packaging/installers/windows/InstallerLauncher.csproj index fc189776a..b73a50369 100644 --- a/packaging/installers/windows/InstallerLauncher.csproj +++ b/packaging/installers/windows/InstallerLauncher.csproj @@ -5,6 +5,7 @@ OpenHCS-Windows-Installer OpenHCS.Installer latest + false diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index 0bf66740e..093b5f8e2 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -42,6 +42,7 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert 'start ""' in cmd assert "WinExe" in project assert "net48" in project + assert "false" in project assert "" not in project assert "" not in project assert "" not in project @@ -58,6 +59,9 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert '"installer_contract.json"' in build assert "Assembly.GetExecutingAssembly()" in launcher + assert "Environment.Is64BitOperatingSystem" in launcher + assert "!Environment.Is64BitProcess" in launcher + assert "native 64-bit Windows PowerShell" in launcher assert "GetManifestResourceStream(resourceName)" in launcher assert 'Guid.NewGuid().ToString("N")' in launcher assert "Path.GetTempPath()" in launcher From afc500592d234740cf51dc8f8ddfaea7bbd18257 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 23 Jul 2026 15:20:05 -0400 Subject: [PATCH 3/5] Reset Windows PowerShell module discovery --- packaging/installers/windows/InstallerLauncher.cs | 4 ++++ tests/installer/test_windows_simple_installer.py | 1 + 2 files changed, 5 insertions(+) diff --git a/packaging/installers/windows/InstallerLauncher.cs b/packaging/installers/windows/InstallerLauncher.cs index 03a5518c2..1f3a5868c 100644 --- a/packaging/installers/windows/InstallerLauncher.cs +++ b/packaging/installers/windows/InstallerLauncher.cs @@ -77,6 +77,10 @@ private static int Main(string[] arguments) CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden, }; + // A launcher started from PowerShell 7 can inherit PS7-only module + // paths. Windows PowerShell reconstructs its correct user, system, + // and built-in module paths when the inherited override is absent. + startInfo.EnvironmentVariables.Remove("PSModulePath"); StringBuilder powerShellArguments = new StringBuilder( "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File " ); diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index 093b5f8e2..33c2fda2b 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -74,6 +74,7 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert "UseShellExecute = false" in launcher assert "CreateNoWindow = true" in launcher assert "WindowStyle = ProcessWindowStyle.Hidden" in launcher + assert 'startInfo.EnvironmentVariables.Remove("PSModulePath")' in launcher assert "startInfo.ArgumentList" not in launcher assert ( "powerShellArguments.Append(QuoteWindowsArgument(installerScript))" in launcher From 33850e6fc53c3042db9c67fd00307a462f42cc5d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 23 Jul 2026 15:24:23 -0400 Subject: [PATCH 4/5] Wait for the Windows GUI installer in CI --- .github/workflows/integration-tests.yml | 25 +++++++++++++++---- .../test_windows_simple_installer.py | 3 +++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c0be6bb94..ac007c2db 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -533,11 +533,26 @@ jobs: $cancellationPath = Join-Path ` ([IO.Path]::GetTempPath()) ` ("openhcs-installer-cancel-{0}.marker" -f [Guid]::NewGuid().ToString("N")) - & $launcher ` - -Worker -InstallRoot $installRoot ` - -CancellationPath $cancellationPath - if ($LASTEXITCODE -ne 0) { - throw "Windows installer failed with exit code $LASTEXITCODE." + $installerStartInfo = [Diagnostics.ProcessStartInfo]::new() + $installerStartInfo.FileName = $launcher + $installerStartInfo.UseShellExecute = $false + foreach ($argument in @( + "-Worker", + "-InstallRoot", $installRoot, + "-CancellationPath", $cancellationPath + )) { + [void]$installerStartInfo.ArgumentList.Add([string]$argument) + } + $installerProcess = [Diagnostics.Process]::Start($installerStartInfo) + try { + $installerProcess.WaitForExit() + $installerExitCode = $installerProcess.ExitCode + } + finally { + $installerProcess.Dispose() + } + if ($installerExitCode -ne 0) { + throw "Windows installer failed with exit code $installerExitCode." } $desktopRoot = [Environment]::GetFolderPath("DesktopDirectory") diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index 33c2fda2b..750e2388c 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -348,6 +348,9 @@ def test_windows_installer_ci_has_an_absolute_safety_ceiling() -> None: assert "Length -gt 2MB" in smoke_step assert '"openhcs-installer-cancel-{0}.marker"' in smoke_step assert "-CancellationPath $cancellationPath" in smoke_step + assert "$installerStartInfo.ArgumentList.Add([string]$argument)" in smoke_step + assert "$installerProcess.WaitForExit()" in smoke_step + assert "$installerProcess.ExitCode" in smoke_step def test_windows_release_is_one_directly_runnable_file() -> None: From 8d5d5554fee602940f6b957185960676da849408 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 23 Jul 2026 15:24:49 -0400 Subject: [PATCH 5/5] Update the Windows installer CI contract --- tests/installer/test_windows_simple_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index 750e2388c..1b2f547e3 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -347,7 +347,7 @@ def test_windows_installer_ci_has_an_absolute_safety_ceiling() -> None: assert "GUI-subsystem executable" in smoke_step assert "Length -gt 2MB" in smoke_step assert '"openhcs-installer-cancel-{0}.marker"' in smoke_step - assert "-CancellationPath $cancellationPath" in smoke_step + assert '"-CancellationPath", $cancellationPath' in smoke_step assert "$installerStartInfo.ArgumentList.Add([string]$argument)" in smoke_step assert "$installerProcess.WaitForExit()" in smoke_step assert "$installerProcess.ExitCode" in smoke_step