From f2d335b13803de26a158cf05839843a804486b21 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Fri, 9 Oct 2026 14:00:09 -0400 Subject: [PATCH] CI: the MSI is install-tested with a custom folder, upgrade, repair and uninstall (#466) test-msi.ps1 builds the MSI around stub binaries, validates it (ICE) and, silently: installs into a folder with spaces and a non-ASCII name, upgrades without naming a folder (it must stay there, one entry in Apps), repairs a deleted file, uninstalls (files and App Paths gone), and installs to the Program Files default. Run by a windows-latest job in packaging-lint.yml. --- .github/workflows/packaging-lint.yml | 16 ++++- packaging/windows/test-msi.ps1 | 91 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 packaging/windows/test-msi.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 81b150bc..91764b06 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,5 +1,6 @@ # Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, -# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. Plus a +# Windows MSI install test with stub binaries (install folder, upgrade, repair, uninstall). name: Packaging lint on: @@ -66,3 +67,16 @@ jobs: EOF - name: FreeBSD package layout (stub binaries) run: bash packaging/freebsd/package.sh --dry-run + + windows-msi: + name: Windows MSI install folder (stub binaries) + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 + - name: Install, upgrade, repair and uninstall + shell: pwsh + run: ./packaging/windows/test-msi.ps1 diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 new file mode 100644 index 00000000..076a8bab --- /dev/null +++ b/packaging/windows/test-msi.ps1 @@ -0,0 +1,91 @@ +<# +.SYNOPSIS + Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). + +.DESCRIPTION + Builds the MSI twice (versions 0.0.1 and 0.0.2) around two stand-in executables, validates both + (ICE), then, silently and per-machine: + 1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...), + 2. upgrades to 0.0.2 without naming a folder: it must stay in that folder, + 3. repairs a deleted file there, + 4. uninstalls: files and the App Paths registration go, + 5. installs 0.0.2 with no folder: it must land in Program Files, then uninstalls. + The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn + and upgrades do with the folder. Needs an elevated shell and WiX v5 (`wix`) on PATH. + Run by .github/workflows/packaging-lint.yml. + +.EXAMPLE + pwsh packaging/windows/test-msi.ps1 +#> +param([string] $Work = (Join-Path ([IO.Path]::GetTempPath()) 'effectcraft-msi-test')) +$ErrorActionPreference = 'Stop' +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$AppPaths = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe' + +function Invoke-Native([string] $What, [scriptblock] $Block) { + Write-Output "==> $What" + & $Block + if ($LASTEXITCODE -ne 0) { throw "$What failed with exit code $LASTEXITCODE" } +} +function Invoke-Msiexec([string] $What, [string[]] $Arguments) { + $log = Join-Path $Work "$($What -replace '\W', '-').log" + $p = Start-Process msiexec.exe -ArgumentList ($Arguments + '/qn', '/l*v', "`"$log`"") -Wait -PassThru + if ($p.ExitCode -ne 0) { Get-Content $log -Tail 60; throw "msiexec ($What) exited $($p.ExitCode)" } + Write-Output "ok $What" +} +function Assert-InstalledIn([string] $Dir) { + foreach ($exe in 'effectcraft.exe', 'effectcraft-cli.exe') { + if (-not (Test-Path -LiteralPath (Join-Path $Dir $exe))) { throw "$exe is not in $Dir" } + } + $key = Get-ItemProperty -LiteralPath $AppPaths + $want = Join-Path $Dir 'effectcraft.exe' + if ($key.'(default)' -ne $want) { throw "App Paths points at '$($key.'(default)')', expected '$want'" } + if ($key.Path.TrimEnd('\') -ne $Dir.TrimEnd('\')) { throw "App Paths Path is '$($key.Path)', expected '$Dir'" } + Write-Output "ok installed in $Dir" +} + +Remove-Item -Recurse -Force $Work -ErrorAction SilentlyContinue +$Bin = Join-Path $Work 'bin' +New-Item -ItemType Directory -Force -Path $Bin | Out-Null +# Any GUI and console executables will do: the MSI only copies them. +Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') +Copy-Item (Join-Path $env:SystemRoot 'System32\whoami.exe') (Join-Path $Bin 'effectcraft-cli.exe') + +$Msi = @{} +foreach ($v in '0.0.1', '0.0.2') { + $Msi[$v] = Join-Path $Work "effectcraft-$v.msi" + Invoke-Native "wix build $v" { + wix build (Join-Path $PSScriptRoot 'effectcraft.wxs') (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch x64 ` + -d "Version=$v" -d "BinDir=$Bin" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" -o $Msi[$v] + } + Invoke-Native "wix msi validate $v" { wix msi validate $Msi[$v] } +} + +# Spaces and a non-ASCII letter (written as a char so the script's own encoding doesn't matter). +$Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My Apps" +$Default = Join-Path $env:ProgramFiles 'EffectCraft' +if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed here; run this on a clean machine' } + +Invoke-Msiexec 'install to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"") +Assert-InstalledIn $Custom +if (Test-Path -LiteralPath $Default) { throw "a custom install also wrote $Default" } + +Invoke-Msiexec 'upgrade without a folder' @('/i', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Custom +$arp = @(Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | + Get-ItemProperty | Where-Object { $_.DisplayName -eq 'EffectCraft' }) +if ($arp.Count -ne 1 -or $arp[0].DisplayVersion -ne '0.0.2') { throw "expected one EffectCraft 0.0.2 in Apps, found: $($arp.DisplayVersion -join ', ')" } + +Remove-Item -LiteralPath (Join-Path $Custom 'effectcraft-cli.exe') +Invoke-Msiexec 'repair' @('/fa', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Custom + +Invoke-Msiexec 'uninstall the custom install' @('/x', "`"$($Msi['0.0.2'])`"") +if (Test-Path -LiteralPath (Join-Path $Custom 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +if (Test-Path -LiteralPath $AppPaths) { throw 'uninstall left the App Paths registration behind' } + +Invoke-Msiexec 'install to the default folder' @('/i', "`"$($Msi['0.0.2'])`"") +Assert-InstalledIn $Default +Invoke-Msiexec 'uninstall the default install' @('/x', "`"$($Msi['0.0.2'])`"") +if (Test-Path -LiteralPath (Join-Path $Default 'effectcraft.exe')) { throw 'uninstall left effectcraft.exe behind' } +Write-Output 'MSI folder handling ok'