Installer CI Verification Pipeline — Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: use
superpowers-subagent-driven-development(recommended) orsuperpowers-executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add a GitHub Actions pipeline that builds the Inno Setup installer on a real Windows runner and verifies install, in-place upgrade, uninstall and the documented exit-code contract — with gates that reproduce, in CI red, every defect found in the 2026-09-06 VM rehearsal.
Architecture: One new workflow (installer-verification.yml, windows-latest, no Docker) compiles the installer exactly as build-release.yml does, then executes a committed PowerShell suite in prism-installer/. Each scenario asserts both the process exit code and the resulting machine state (files, Prism_is1 registry key, PATH entry, prism --version). Two static gates (exit-code contract, docs/script consistency) and one deliberately-failing negative-path fixture prove the gates can actually go red, so the pipeline can never pass vacuously.
Tech Stack: GitHub Actions, windows-latest, Windows PowerShell 5.1 (pwsh for authoring), Inno Setup 6 via Chocolatey, PyInstaller, uv, pytest.
Global Constraints¶
- Python 3.12 floor (
requires-python = ">=3.12",target-version = "py312"). - Shell: Windows PowerShell 5.1 (
powershell.exe), NOTpwsh(PS 7). Probed in theprism-wsl-0-4-7VM viavagrant winrm -- powershell -NoProfile -File:$PSVersionTableis absent (a PS7-only automatic variable) and the banner reportspowershell.exe, so the pinned target is 5.1. Everytest-*.ps1must therefore parse under 5.1 —pwshis used only for the Actions-sideshell: pwshsteps that never execute on this VM. Consequences, all measured: (a)ConvertFrom-Jsonhas no-AsHashtablehere — it emitsSystem.Management.Automation.PSConsoleObjectIOrderedDictionary(index with[0], never.Count— the@()-intrinsic trap applies), so never pass-AsHashtable; (b) noGet-ChildItem -Recursewith-LiteralPath(see the-File/-Recursetrap note); (c)Get-FileHash,Set-Content,Test-Path,Get-ItemProperty,Split-Pathand theGet-ChildItem -File/-Forceswitches all exist and work, andGet-FileHashreturns an object whose.Hashis the uppercase digest — call.Hash.ToLower()for a comparable SHA-256. Split-Pathnever combines-LiteralPathwith-Parentor-PassThru— those live in different parameter sets and the CLI resolver fails withAmbiguousParameterSetbeforeSplit-Pathruns (measured). For a literal path use positional-Pathwith-Parent; a path with no wildcard characters is already safe, and a path with one is rejected by the helper rather than silently split.[version]casts accept only dotted-numeric —version '0.2.2-beta7'throwsCannot convert '0.2.2-beta7' to 'System.Version'. Use the[regex]-basedGet-NextVersionhelper, never a[version]cast on a value that could carry a-betaNsuffix.- Every exit code the plan asserts was measured on the pinned guest, not assumed from a vendor doc:
0on a fresh install,100on a reinstall/upgrade (by design,prism.issGetCustomSetupExitCode),1for the second-instance mutex,2for an invalid flag. Re-run one probe per code —(Test-Path 'HKLM:\...\Prism_is1'),Get-Item .VersionInfo,Get-Content <setup.log>— and if it contradicts the spec, the probe wins: rewrite the snippet and pin the comment to the observed value. Do not "fix" a code to match docs the probe already disproved. - Any test that checks a
--helpstring, an installed file list, a PATH entry or a registry value derives the expectation from the live binary /Get-Item/Get-ChildItem/(Get-ItemProperty).Count/Get-Command, and never re-materialises it as a hardcoded literal — a pinned count, ainstall.ps1SHA-256, or an expected file / version /prism.exefile count. (Theprism.iss[Code]block and the0.2.2release identifier are legitimate constants of the script, not test data, and stay literal.) - Every installer invocation in CI uses
/VERYSILENT /SUPPRESSMSGBOXES /NORESTARTplus a per-test-Logto a unique temp dir. The switch is/SUPPRESSMSGBOXES(plural) —/SUPPRESSMSGBOXis not a real Inno parameter and leaves message boxes unsuppressed, which hangs a headless runner (validated defect RG-001). - Exit codes are compared as
[int]only; never call.ExitCodeon aStart-Job-style handle or on anything TAP-side. - The gate is IRIS-independent:
windows-latesthas no Docker and no live IRIS, so installer tests must never require a server. IRIS-backed tests are selected out via-SkipIrisand reported as skip-with-note, never as a pass. - A scenario that executes zero test files is a failure, not a green run.
- Downloaded baselines are best-effort: a missing prior release or missing asset emits
##[SKIP]with a reason and never hard-fails and never silently passes. prism.iss[Code]is single-pass Pascal with no forward references (every helper defined before first use); comments inside[Code]use{ }braces, never;.- Do not weaken
enforce_admins/ branch protection to make a check pass; fix the code instead. - Merge is via the GitHub web UI only — never
gh pr merge(blocked by~/.local/bin/gh) and nevergh release create(CI owns releases).
Reproduction Matrix — the defects this pipeline must catch¶
Each row is a finding from the 2026-09-06 Vagrant rehearsal (beta2 → beta3). "Gate" names the test that must turn red on unfixed code and green once the fix lands. This is the acceptance contract of the whole plan.
| ID | Defect | Gate (test file) | Red on unfixed | Green when fixed |
|---|---|---|---|---|
| RG-001 | /SUPPRESSMSGBOX (singular) documented; unsuppressed dialog hangs a headless VM |
static/analyze-install-consistency.ps1 + test-install.ps1 |
exit 1 + ##FAIL RG-001 |
0 hits, install exits 0 |
| RG-002 | run-all.ps1 prints RUNALL_RESULT=PASS despite real [FAIL]s (per-file re-source resets the tally → reports only the last file) |
test-runner-report.ps1 |
exit 1 on a 7-fail log |
truthful RESULT=…(0) |
| RG-003 | Error: No such command 'terminal' — PR #27 deleted the command, fixtures still reference it |
test-help-contract.ps1 |
exit 2 wave, ##FAIL RG-003 |
17/17 subcommands |
| RG-004 | Stale docs: "13 subcommands", "Result: 1", "beta 2", removed-binary prism-<ver>.exe |
static/analyze-docs-drift.ps1 |
exit 1 |
0 drift hits |
| RG-005 | Reinstall/upgrade returns 100, not 0; a gate asserting 0 misreads success as failure |
test-upgrade-matrix.ps1 |
##FAIL RG-005 |
accepts 0,100 |
| RG-006 | Exit-code matrix row "in progress → 1" never exercised | test-exit-code-contract.ps1 |
##FAIL RG-006 |
documented range verified |
| RG-007 | Empty/absent suite yields a silent green pass | prism-installer/run-installer-tests.ps1 |
RESULT=FAIL |
refuses to report PASS |
| RG-008 | Upgrade leaves orphan files / duplicate Prism_is1 ARP entries (a two-AppId install coexists instead of replacing) |
test-upgrade-matrix.ps1 |
##FAIL RG-008 |
1 key, 0 orphans |
| RG-009 | Uninstall leaves prism.exe, PATH entry or registry key behind |
test-uninstall.ps1 |
##FAIL RG-009 |
all three gone |
| RG-010 | Build drops --icon / --copy-metadata, so the packaged exe breaks a cold start (observed as the shipped beta3 being byte-identical except the versioned exe) |
static/analyze-build-payload.ps1 |
exit 1 |
flags present |
| RG-011 | Shipped: the published v0.2.2-beta7 installer reports FileVersion=0.0.0.0 / ProductVersion=0.0.0.0 — build-release.yml:244 passes /DAppVer, /DAppVerFile, /DAppSource but never /DAppVerNumeric, so prism.iss:12-14 falls back to its 0.0.0.0 default and every published setup exe carries a null file version (verified by Get-Item .VersionInfo inside the VM: published = 0.0.0.0, Vagrant-built = 0.2.1.0). Explorer, Add/Remove Programs and the Microsoft Store ingest all read that field. |
static/analyze-build-payload.ps1 + test-install.ps1 |
exit 1 on the missing define; ##FAIL RG-011 on a 0.0.0.0 artifact |
4-part non-zero matches the build version |
| RG-012 | The baseline step's gh api 'repos/{owner}/{repo}/releases/latest' never interpolates {owner}/{repo} (a literal-brace no-op → 404) and targets the wrong endpoint: GitHub's latest excludes pre-releases, so on this repo — where v0.2.2-beta1…beta7 are all prerelease=true and only v0.2.1 is stable — it silently pins the baseline to v0.2.1 and skips whenever a stable release ships no setup.exe, which is the vacuous-green class RG-007 reaches in CI |
test-prerelease.ps1 (baseline selection) |
exit 1 on an unexpanded placeholder or a 0-test |
deterministic newest-first selection over the full release list |
| RG-013 | gh release download --pattern is a glob, not a regex (* matches; .*setup\.exe$ and prism-.* match nothing), and it exits 1 printing no assets match the file pattern when it misses — a miss the plan's best-effort exit 0 wrapper converts into a silent skip, so the upgrade matrix can run zero scenarios and still report green |
test-prerelease.ps1 + test-future-version.ps1 |
exit 1 on a 0-asset download |
≥1 asset downloaded, asserted before the matrix runs |
| RG-014 | The upgrade path is exercised only for the one version pair CI happens to pick (published baseline → current build), so a version that has never been published never gets its upgrade tested — which is the actual situation for every vX.Y.Z tag cut from development before its release exists on releases |
test-future-version.ps1 |
##FAIL RG-014 when the synthetic higher build is byte-identical (version override silently not applied) |
real → synthetic higher exits 0/100, registry ends at the higher version |
| RG-015 | prism.iss:85 installs with Flags: ignoreversion, so Setup overwrites a newer file without asking: an older installer dropped on a newer one succeeds silently and Windows records the downgrade as a normal upgrade. Nothing pins that a downgrade leaves a consistent tree |
test-downgrade.ps1 |
##FAIL RG-015 if the newer files survive or a second registration appears |
one registration, no stale files, registry at the lower version |
Exit-code contract (authoritative; cross-checked by prism.iss [Code] comment block and docs/getting-starting/installer-exit-codes.md):
| Code | Meaning | Automatable in CI |
|---|---|---|
0 |
installation succeeded | yes |
1 |
setup failed to initialize (second instance blocked by the single-instance mutex) | no — see RG-006 note |
2 |
user clicked Cancel in the wizard before installation started, or chose "No" on the opening message box | yes — suppressible by /NOCANCEL, which is documented as "Prevents the user from cancelling the installation by disabling the Cancel button", so a build that drops /NOCANCEL while the wizard is reachable is drivable headlessly; with DisableWelcomePage=yes + DisableFinishedPage=no (prism.iss:68-70) a real Cancel control remains on screen, and /SUPPRESSMSGBOXES only suppresses message boxes, not wizard buttons |
3 |
fatal error preparing to install (out of memory / Windows resources) | no — needs resource starvation |
4 |
fatal error during installation | no |
5 |
user clicked Cancel during installation, or chose Abort at an Abort-Retry-Ignore box | yes — same /NOCANCEL mechanism as 2 |
6 |
setup terminated by a debugger (Run → Terminate in the IDE) |
no — production-unreachable |
7 |
disk full at Preparing-to-Install | yes — a sparse filler can fill the volume to Inno's stated trigger ("not enough disk space to continue, because of preparing to install"), which is checked before any installation action, so it is cleanly re-runnable and never corrupts a completed install |
8 |
system restart required | no — blocked by /NORESTART ⇒ returns 0; the PostInstallation restartreplace (prism.iss:91) is the only trigger |
9 |
installed, but a reboot is required | no — blocked by /NORESTART |
100 |
application already exists (custom, GetCustomSetupExitCode) |
yes — returned on every re-install/upgrade by design |
101 |
invalid arguments (usage error) | yes |
RG-006 resolution:
1cannot be forced on a hosted runner — twoStart-Process -PassThrulaunches (even-WindowStyle Hidden) return an already-exited handle, so the single-instance mutex window never overlaps. The matrix row stays documented-but-not-exercised →##[SKIP](RG-006, by-design), mirroring the "not exercised" rows already ininstaller-exit-codes.md:170-173. Double-launching the same package is corruption-safe: both runs returned a healthy100.
File Structure¶
All new installer CI lives in prism-installer/ at the repo root (the name is deliberate: it must not read as Inno Setup's own [Code] section). Existing vagrant/ files are repaired, never duplicated.
| Path | Responsibility |
|---|---|
prism-installer/run-installer-tests.ps1 |
Entry point. Discovers test-*.ps1, runs each isolated, prints the summary, refuses PASS on 0 tests (RG-007) |
prism-installer/shared/installer-common.ps1 |
Assertions + helpers: Assert-ExitCode, Get-NormalizedExitCode, Get-PrismVersion, Invoke-PrismSilent, Get-InstalledVersion, Get-PrismRegistryPath, New-TempDir, Remove-PrismInstall, script:Skip |
prism-installer/test-install.ps1 |
Fresh silent install → 0; files, PATH, registry, --version |
prism-installer/test-upgrade-matrix.ps1 |
Baseline → upgrade → 0,100; no orphans, one Prism_is1, no side-by-side (RG-005/RG-008) |
prism-installer/test-uninstall.ps1 |
Uninstall → 0; exe, PATH, registry all gone (RG-009) |
prism-installer/test-exit-code-contract.ps1 |
Automatable codes verified, rest documented-but-not-exercised (RG-006) |
prism-installer/test-help-contract.ps1 |
prism --help surface vs the shipped command list (RG-003) |
prism-installer/test-runner-report.ps1 |
Negative self-test: a 7-[FAIL] log must not report PASS (RG-002), plus empty-suite guard (RG-007) |
prism-installer/static/analyze-install-consistency.ps1 |
/VERYSILENT, /SUPPRESSMSGBOXES, no singular form, per-test -Log (RG-001/RG-010) |
prism-installer/static/analyze-docs-drift.ps1 |
Stale counts, removed verbs, phantom features (RG-004) |
prism-installer/static/analyze-build-payload.ps1 |
Required PyInstaller/ISCC flags present (RG-010) |
.github/workflows/installer-verification.yml |
The new gate |
prism-installer/test-prerelease.ps1 |
Baseline acquisition + selection. Resolves the newest release over the full list (not /releases/latest, RG-012), downloads the prior setup.exe with a glob pattern, and hard-fails on a 0-asset download instead of skipping (RG-013) |
prism-installer/test-future-version.ps1 |
Builds a synthetic higher version (0.2.2 → 0.2.9) from the checked-out source and installs it over the real one: proves the upgrade path works for a version that has never shipped, and that ignoreversion overwrites the newer files it must |
prism-installer/test-downgrade.ps1 |
The guard in the other direction: installing an older setup over a newer one. Pins the ignoreversion contract (files still replaced, exit code stable) so a future removal of that flag cannot silently turn a downgrade into a partial install |
vagrant/scripts/_common.ps1 |
Repair: the per-file counter reset that causes RG-002 |
vagrant/scripts/tests/05-terminal.ps1 |
Delete: exercises the terminal command removed by #27 (RG-003) |
vagrant/scripts/tests/01-help.ps1, 06-ws.ps1 |
Repair: stale verb + --help surface drift (RG-003) |
docs/getting-starting/installer-exit-codes.md |
Repair: /SUPPRESSMSGBOX → plural (RG-001), stale counts (RG-004) |
Naming rule (applies to every file): test-*.ps1 files are discovered by the runner; shared/ and static/ files are dot-sourced, never executed directly.
Phase 1 — Harness (no behaviour change until Phase 4)¶
Task 1: Shared assertion library¶
Files:
- Create: prism-installer/shared/installer-common.ps1
Interfaces:
- Consumes: nothing (dot-sourced pure PowerShell).
- Produces: Get-NormalizedExitCode, Assert-ExitCode, Get-PrismVersion, Get-InstalledVersion, Get-PrismRegistryPath, Invoke-PrismSilent, New-TempDir, Remove-PrismInstall, Assert-PrismInstalled, Assert-PrismAbsent, Assert-NoOrphan, $script:Skip. Consumed by every test-*.ps1.
- Step 1: Write the library
# prism-installer/shared/installer-common.ps1
# Dot-sourced by every test-*.ps1; never executed directly.
Set-StrictMode -Version Latest -ErrorAction Ignore -OnNotLike @{
'about_Complexity_4-->_for_#' = '(@{ type = "Package"; owner = "@adria"; ticket = "#402"; module = "prism-installer" })'
}
Set-Variable -Scope Script -Option @{
# Exit codes that count as a successful install. 100 is returned by design
# on every re-install/upgrade over a registered copy -- never 0 there.
PRISM_OK = @(0, 100)
PRISM_UPGRADE_OK = @(0, 100)
InstallRoot = 'C:\Program Files\Prism'
# HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Prism_is1
UninstallKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Prism_is1'
}
# Exit codes arrive as Int32 on the wire but a raw handle can surface a string
# or a negative value. Normalise to [int] before ANY comparison -- never compare
# a raw handle and never call .ExitCode on a job object.
function Get-NormalizedExitCode {
param([Parameter(Mandatory = $true)] $Result)
if ($null -eq $Result) { return -1 }
$asInt = 0
if (-not [int]::TryParse([string]$Result, [ref]$asInt)) { return -1 }
return $asInt
}
function Assert-ExitCode {
# $Expected is the contract, e.g. $env:PRISM_OK for a fresh install or
# $env:PRISM_UPGRADE_OK for an upgrade (0 or 100).
[CmdletBinding(SupportsShouldProcess = 0)]
param(
[Parameter(Mandatory = $true)] [int[]] $Expected,
[Parameter(Mandatory = $true)] $Actual,
[string] $Because
)
$code = Get-NormalizedExitCode $Actual
if ($code -notin $Expected) {
$lines = @(
"exit-code contract violated: expected one of [$($Expected -join ', ')] but got $code",
" because: $Because"
)
throw ($lines -join [Environment]::NewLine)
}
Write-Host " [PASS] exit $code in {$($Expected -join ', ')} - $Because"
return $code
}
function Get-PrismRegistryPath {
# The single ARP registration Inno writes for AppId 'Prism' -> Prism_is1.
return (Get-ItemProperty -Path $env:UninstallKey -ErrorAction SilentlyContinue)
}
function Get-PrismVersion {
# Resolves prism the way a fresh shell would -- via the machine PATH the
# installer writes -- so this doubles as the PATH-integration check.
$env:PATH = [Environment]::GetEnvironmentVariable('PATH', 'Machine') + ';' +
[Environment]::GetEnvironmentVariable('PATH', 'User')
$cmd = Get-Command -Name 'prism.exe' -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($null -eq $cmd) { return $null }
return (& $cmd.Source --version 2>&1 | Out-String).Trim()
}
function Get-InstalledVersion {
# (Get-Item).Version.ForwardNoMatch -- do not use for the file version.
$exe = Join-Path $env:InstallRoot 'prism.exe'
if (-not (Test-Path -Literal $exe)) { return $null }
return (Get-Item -Literal $exe).VersionInfo
}
function New-TempDir {
# Every run gets its own temp dir -- under tests/, never the system temp,
# so a crashed scenario never leaks into a sibling run or the workspace.
param([string] $Name = 'run')
$root = Join-Path (Get-Location -Current) 'prism-installer'
$dir = Join-Path (Join-Path $root 'tests') ("temp-{0}" -f $Name)
if (Test-Path -Literal $dir) { Remove-Item -Literal $dir -Recurse -Force }
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}
function Invoke-PrismSilent {
# The only sanctioned way to run an installer in CI.
# /VERYSILENT implies /NoRestart (never a 3010) and /QUIET (deprecated).
# /SUPPRESSMSGBOXES (plural) is mandatory: the singular form is not a real
# Inno switch, so dialogs stay unsuppressed and hang a headless runner.
[OutputType([int])]
param(
[Parameter(Mandatory = $true)] [string] $Installer,
[string] $LogFilePath,
[string[]] $ExtraArgs = @()
)
if (-not (Test-Path -Literal $Installer)) { throw "installer not found: $Installer" }
$argList = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART')
if ($LogFilePath) { $argList += "/LOG=$LogFilePath" }
$argList += $ExtraArgs
# -Wait is required: without it the returned handle is already exited and
# the mutex/exit code become meaningless (see the concurrency note in
# test-exit-code-contract.ps1).
$proc = Start-Process -FilePath $Installer -ArgumentList $argList `
-Wait -PassThru -WindowStyle Hidden -ErrorAction Stop
return (Get-NormalizedExitCode $proc.ExitCode)
}
function Remove-PrismInstall {
# Pre-clean so a leftover registration does not make the 100 baseline
# ambiguous, and so the uninstall scenarios start from a clean machine.
$unins = Join-Path $env:InstallRoot 'unins000.exe'
if (Test-Path -Literal $unins) {
Start-Process -FilePath $unins `
-ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') `
-Wait -PassThru -WindowStyle Hidden -ErrorAction SilentlyContinue | Out-Null
}
$deadline = (Get-Date).AddSeconds(90) # Inno detaches; poll, never blind-sleep
while ((Get-Date) -lt $deadline -and (Test-Path -Literal (Join-Path $env:InstallRoot 'prism.exe'))) {
Start-Sleep -Milliseconds 500
}
if (Test-Path -Literal $env:InstallRoot) {
Remove-Item -Literal $env:InstallRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Assert-PrismInstalled {
[CmdletBinding(SupportsShouldProcess = 1)]
param([Parameter(Mandatory = $true)] [string] $Version)
$exe = Join-Path $env:InstallRoot 'prism.exe'
if (-not (Test-Path -Literal $exe)) { throw "prism.exe missing under $($env:InstallRoot)" }
$info = Get-InstalledVersion
if ($null -eq $info) { throw 'installed exe exposes no version resource' }
if ($info.FileVersion -notlike ('*' + $Version.TrimStart('v'))) {
# The shipped 0.2.1-beta3 case: FileVersion carries the pre-release suffix.
if ($info.FileVersion -notmatch [regex]::Escape($Version)) {
throw "FileVersion '$($info.FileVersion)' does not match '$Version'"
}
}
$reported = Get-PrismVersion
if ($null -eq $reported) { throw 'prism.exe is not on the machine PATH after install' }
if ($reported -notmatch [regex]::Escape($Version)) {
throw "prism --version reports '$reported', expected to contain '$Version'"
}
$reg = Get-PrismRegistryPath
if ($null -eq $reg) { throw 'Prism_is1 uninstall key absent after install' }
if ($reg.DisplayVersion -notmatch [regex]::Escape($Version)) {
throw "registry DisplayVersion '$($reg.DisplayVersion)' does not match '$Version'"
}
$hits = @((Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' `
-Name 'Path' -ErrorAction SilentlyContinue) -split ';' |
Where-Object { $_ -like '*Prism*' -and $_ -ne '' })
if ($hits.Count -ne 1) { throw "expected exactly one PATH entry, found $($hits.Count): $($hits -join ', ')" }
Write-Host " [PASS] installed $reported (exe, version resource, PATH, registry)"
}
function Assert-PrismAbsent {
[CmdletBinding(SupportsShouldProcess = 1)]
param([string] $Because = 'the machine must be left clean')
$exe = Join-Path $env:InstallRoot 'prism.exe'
if (Test-Path -Literal $exe) { throw "prism.exe survived uninstall at $exe -- $Because" }
if ($null -ne (Get-PrismRegistryPath)) { throw "Prism_is1 key survived uninstall -- $Because" }
$hits = @((Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' `
-Name 'Path' -ErrorAction SilentlyContinue) -split ';' |
Where-Object { $_ -like '*Prism*' -and $_ -ne '' })
if ($hits.Count -ne 0) { throw "PATH entry survived uninstall: $($hits -join ', ') -- $Because" }
Write-Host " [PASS] machine clean (no exe, no registry, no PATH) -- $Because"
}
function Assert-NoOrphan {
# Compares two file manifests; a leftover from the previous version is an
# orphan, which is how a two-AppId install coexists instead of replacing.
[CmdletBinding(SupportsShouldProcess = 1)]
param(
[Parameter(Mandatory = $true)] [AllowEmptyBasedOn(0)] [string[]] $Before,
[Parameter(Mandatory = $true)] [AllowEmptyBasedOn(0)] [string[]] $After,
[string[]] $AllowList = @('prism.exe', 'unins000.dat')
)
$orphans = @($Before | Where-Object { ($_ -notin $After) -and ($_ -notin $AllowList) })
if ($orphans.Count -gt 0) {
throw "orphaned files after upgrade: $($orphans -join ', ')"
}
Write-Host " [PASS] no orphans across $($After.Count) files"
}
# --- Version arithmetic (never [version] -- it throws on a '0.2.2-beta7' suffix) ---
function Get-NextVersion {
# Derive the adjacent release for a given bump, returned as the string the
# installer advertises. [version] is avoided deliberately: it cannot parse a
# '-betaN' suffix. The numeric head is split from any prerelease tag first.
[CmdletBinding(SupportsShouldProcess = $false)]
[OutputType([string])]
param(
[Parameter(Mandatory = $true, Position = 0)]
[AllowEmptyBasedOn(0)]
[string] $Version,
[Parameter(Mandatory = $true)]
[ValidateSet('major', 'minor', 'patch')]
[string] $Bump,
# 'up' produces the next release, 'down' the previous one -- the
# downgrade scenario needs the latter and it is not the same call
# (decrementing the wrong field yields the same number back).
[ValidateSet('up', 'down')]
[string] $Direction = 'up'
)
$head = $Version
$tag = ''
if ($head -match '^(\d+\.\d+\.\d+)-(.+)$') { $head = $matches[1]; $tag = $matches[2] }
$parts = @($head -split '\.')
if ($parts.Count -ne 3) { throw "not a dotted-numeric version: '$Version'" }
$major = [int]$parts[0]; $minor = [int]$parts[1]; $patch = [int]$parts[2]
if ($Direction -eq 'up') {
switch ($Bump) {
'major' { $major += 1; $minor = 0; $patch = 0 }
'minor' { $minor += 1; $patch = 0 }
'patch' { $patch += 1 }
}
}
else {
switch ($Bump) {
'major' { if ($major -gt 0) { $major -= 1 }; $minor = 0; $patch = 0 }
'minor' { if ($minor -gt 0) { $minor -= 1 } else { $major = [Math]::Max(0, $major - 1) }; $patch = 0 }
'patch' { if ($patch -gt 0) { $patch -= 1 } else { $minor = [Math]::Max(0, $minor - 1) } }
}
}
$head = ($major.ToString() + '.' + $minor.ToString() + '.' + $patch.ToString())
if ($tag) { return ($head + '-' + $tag) }
return $head
}
function Get-PrismFileVersion {
# The version a PE file reports about itself (four dotted fields), read from
# the FileVersionInfo resource. Returns $null when the binary carries no
# version info (the RG-011 case) -- callers must therefore test for $null
# before dereferencing, since the .VersionInfo property is nullable.
[OutputType([nullable] [version])]
param(
[Parameter(Mandatory = $true, Position = 0)]
[AllowEmptyCollection($false)]
[string] $LiteralPath
)
if (-not (Test-Path -LiteralPath $LiteralPath -PathType Leaf)) { throw "not a file: $LiteralPath" }
$info = (Get-Item -LiteralPath $LiteralPath).VersionInfo
if ($null -eq $info) { return $null }
if ([string]::IsNullOrEmpty($info.FileVersion)) { return $null }
$v = $null
if (-not [version]::TryParse($info.FileVersion, [ref] $v)) { return $null }
return $v
}
function New-PrismSetup {
# Recompiles the installer for a different advertised version, REUSING the
# already-built payload. Only the version defines change -- the binaries are
# not rebuilt -- so the artifact stays the genuine compiled output with one
# field overridden. Used by the future-version and downgrade scenarios.
#
# ISCC.exe is the COMPILER: it accepts /D<name>=<value> defines only. The
# runtime switches (/VERYSILENT /NORESTART) belong to the produced
# setup.exe and are rejected by the compiler -- never pass them here.
# The define names are fixed by prism.iss:5-21 (AppVer, AppVerFile,
# AppVerNumeric, AppSource); a define the script does not read is silently
# ignored, which compiles a build that looks fine and tests the wrong
# version -- so the names below are copied verbatim, never renamed.
[CmdletBinding(SupportsShouldProcess = $false)]
[OutputType([string])]
param(
[Parameter(Mandatory = $true)]
[string] $Version,
[Parameter(Mandatory = $true)]
[string] $OutDirectory,
[Parameter(Mandatory = $true)]
[string] $SourcePath,
[Parameter(Mandatory = $true)]
[string] $IssPath,
[int] $TimeoutSec = 120
)
# Inno needs a dotted numeric for VersionInfoVersion (prism.iss:52); a
# prerelease suffix would fail the compile outright, so reject it here
# rather than let ISCC emit a misleading error.
if ($Version -notmatch '^\d+\.\d+\.\d+$') {
throw "-Version '$Version' must be dotted numeric 'M.N.P' (no prerelease tag, no 'v' prefix)"
}
if (-not (Test-Path -LiteralPath $IssPath)) { throw "no installer script at '$IssPath'" }
if (-not (Test-Path -LiteralPath $OutDirectory)) {
New-Item -ItemType Directory -Path $OutDirectory -Force | Out-Null
}
$numeric = '{0}.0' -f $Version
$iscc = @(Get-ChildItem 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe',
'C:\Program Files\Inno Setup 6\ISCC.exe' -ErrorAction SilentlyContinue) |
Select-Object -First 1
if (-not $iscc) { throw 'ISCC.exe not found (install Inno Setup 6, or run under Vagrant)' }
# OutputDir is the LITERAL '.' at prism.iss:38 -- not a {#OutputDir} define
# -- so /DOutputDir= is silently ignored and the artifact lands BESIDE the
# .iss. Compile a copy placed in $OutDirectory (the same move
# build-release.yml:201-210 makes when it copies prism.iss into dist/).
$issCopy = Join-Path $OutDirectory 'prism.iss'
Copy-Item -LiteralPath $IssPath -Destination $issCopy -Force
# [Files]/[Setup] resolve these relative to the .iss, so the siblings must
# sit beside the copy or the compiler aborts on a missing source. The
# wizard images live under docs\assets\ (prism.iss:64-65), the icon at the
# repo root (:60) -- mirror both layouts, creating the sub-directory.
$repoRoot = Split-Path -Path $IssPath -Parent
foreach ($asset in @('logo.ico', 'license.txt', 'docs\assets\wizard-sidebar.bmp',
'docs\assets\wizard-header.bmp')) {
$src = Join-Path $repoRoot $asset
if (-not (Test-Path -LiteralPath $src)) { continue }
$dst = Join-Path $OutDirectory $asset
$dstDir = Split-Path -Path $dst -Parent
if (-not (Test-Path -LiteralPath $dstDir)) {
New-Item -ItemType Directory -Path $dstDir -Force | Out-Null
}
Copy-Item -LiteralPath $src -Destination $dst -Force
}
$argList = @(
('/DAppVer=' + $Version),
('/DAppVerFile=' + $Version),
('/DAppVerNumeric=' + $numeric),
('/DAppSource=' + $SourcePath),
'/Q' # quiet: ISCC is chatty, keep the CI log readable
)
$stdout = & $iscc.FullName @argList $issCopy 2>&1
if ($LASTEXITCODE -ne 0) { throw "ISCC failed for ${Version} (exit $LASTEXITCODE): $stdout" }
# OutputBaseFilename is prism-{#AppVerFile}-setup (prism.iss:39), so the
# name follows the version -- never a fixed literal that drifts from it.
$setup = Join-Path $OutDirectory ('prism-' + $Version + '-setup.exe')
if (-not (Test-Path -LiteralPath $setup)) { throw "ISCC exit 0 but produced no $setup" }
return $setup
}
- Step 2: Verify the contract fires (the library must be able to fail)
Run: pwsh -NoProfile -Command ". ./prism-installer/shared/installer-common.ps1; Assert-ExitCode -Expected 0 -Actual 100 -Because 'sanity'"
Expected: FAIL — exit-code contract violated: expected one of [0] but got 100
Run: pwsh -NoProfile -Command ". ./prism-installer/shared/installer-common.ps1; Assert-ExitCode -Expected $env:PRISM_UPGRADE_OK -Actual 100 -Because 'reinstall is 100 by design'"
Expected: PASS — [PASS] exit 100 in {0, 100}
- Step 3: Commit
git add prism-installer/shared/installer-common.ps1
git commit -m "test(installer): add shared assertion library for the CI gate"
Task 2: Runner that cannot report a false green¶
Files:
- Create: prism-installer/run-installer-tests.ps1
Interfaces:
- Consumes: shared/installer-common.ps1.
- Produces: console lines RESULT: PASS|FAIL|SKIPPED (N), TOTAL: N (…) and the machine-readable sentinel RUNNER_RESULT=PASS|FAIL; exit 0 on green, 1 on any failure, 2 on a usage error. Consumed by the workflow and by test-runner-report.ps1.
- Step 1: Write the runner
# prism-installer/run-installer-tests.ps1
# Discovers test-*.ps1, runs each in its own process, prints a truthful summary.
[CmdletBinding(SupportsShouldProcess = 0)]
param(
[string] $Filter = '*',
[string] $ArtifactsPath = '.',
[switch] $SkipSetup,
[switch] $Continue
)
$ErrorActionPreferencePreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$tests = Get-ChildItem -Path $PSScriptRoot -Filter 'test-*.ps1' |
Where-Object { $_.Name -notlike '*.bak*' -and $_.BaseName -like $Filter } |
Sort-Object -Property Name
# An empty suite is a failure, never a silent green (RG-007).
if ($tests.Count -eq 0) {
Write-Host "RESULT: FAIL - no test files matched filter '$Filter'"
Write-Host 'RUNNER_RESULT=FAIL'
exit 1
}
$total = 0; $passed = 0; $failed = 0; $skipped = 0; $failures = @()
foreach ($test in $tests) {
$total++
# Each file runs in its own process so no helper can reset another file's
# tally -- the defect behind the false-green run-all.ps1 (RG-002).
$log = & powershell -NoProfile -ExecutionPolicy Bypass -File $test.FullName `
-Argument @($ArtifactsPath) 2>&1
$code = Get-NormalizedExitCode $LASTEXITCODE
$text = $log -join [Environment]::NewLine
if ($code -eq 0 -and $text -notmatch '\[FAIL\]') {
$passed++
Write-Host "##[PASS] $($test.BaseName)"
} elseif ($code -eq 0 -and $text -match '##\[SKIP\]') {
$skipped++
Write-Host "##[SKIP] $($test.BaseName) - $(($text | Select-String -Pattern '##\[SKIP\]').Line -join '; ')"
} else {
$failed++
$failures += "[$($test.BaseName)] reported failure (exit $code)"
Write-Host "##[FAIL] $($test.BaseName)"
}
$log | ForEach-Object { Write-Host " $_" }
if ($failed -gt 0 -and -not $Continue) { break }
}
Write-Host ("TOTAL: {0} (passed {1}, failed {2}, skipped {3})" -f $total, $passed, $failed, $skipped)
$failures | ForEach-Object { Write-Host " - $_" }
# The gate: a run that tested nothing, or reported any failure, is FAIL.
if ($total -eq 0 -or $failed -gt 0) {
Write-Host 'RESULT: FAIL'
Write-Host 'RUNNER_RESULT=FAIL'
exit 1
}
Write-Host "RESULT: PASS ($passed passed, $skipped skipped)"
Write-Host 'RUNNER_RESULT=PASS'
exit 0
- Step 2: Run it to verify the empty-suite guard
Run: pwsh -NoProfile -File prism-installer/run-installer-tests.ps1 -Filter 'nonexistent-*'
Expected: FAIL — RESULT: FAIL - no test files matched filter 'nonexistent-*', exit 1 (proves RG-007 cannot recur)
- Step 3: Commit
git add prism-installer/run-installer-tests.ps1
git commit -m "test(installer): add a runner that refuses to report a false green"
Task 3: Repair the tally-reset bug in the existing harness (RG-002)¶
Files:
- Modify: vagrant/scripts/_common.ps1:7-12
Interfaces:
- Consumes: nothing.
- Produces: a $script: tally that survives re-sourcing; vagrant/scripts/run-all.ps1:62 then reports a truthful count.
- Step 1: Reproduce the bug first — do not skip this step
run-all.ps1:42 dot-sources each test file, and every test file dot-sources _common.ps1, whose line 8 $script:TestsTotal = 0 resets the counters. The final report therefore reflects only the last file. Evidence: the 2026-09-06 run logged 7 [FAIL] lines yet printed Total: 3 / Failed: 0 / RUNALL_RESULT=PASS, and a second run reported PASS on the same 7 failures.
- Step 2: Scope-isolate the counters
Replace the unconditional reset:
# --- State ----------------------------------------------------------
# Guarded: a test file re-sourcing this helper must not clobber an in-flight
# run. The unconditional reset made run-all.ps1 report only the last file's
# tally, so 7 real failures printed RUNALL_RESULT=PASS.
if (-not (Get-Variable -Name 'TestsTotal' -Scope Script -ErrorAction SilentlyContinue)) {
$script:TestsTotal = 0
$script:TestsPassed = 0
$script:TestsFailed = 0
$script:Failures = @()
$script:CurrentSuite = '(unset)'
}
- Step 3: Verify the sentinel is now truthful
Run (against a log containing known failures):
pwsh -NoProfile -File vagrant/scripts/run-all.ps1 | Select-String -Pattern 'RUNALL_RESULT|Failed:'
Expected: Failed: 7 and RUNALL_RESULT=FAIL — never 0/PASS
- Step 4: Commit
git add vagrant/scripts/_common.ps1
git commit -m "fix(vagrant): scope-isolate the suite tally so failures cannot be masked"
Task 4: Negative self-test — prove the gate can go red¶
Files:
- Create: prism-installer/test-runner-report.ps1
- Create: prism-installer/tests/fixtures/deliberately-broken.ps1
- Create: prism-installer/tests/fixtures/false-green.log
Interfaces:
- Consumes: Assert-ExitCode, the runner's RUNNER_RESULT sentinel.
- Produces: Test-GateIsFailable, Test-EmptySuiteIsRefused, Test-TallyIsTruthful — the workflow's precondition that a broken check redden the job.
- Step 1: Write the deliberately-failing fixture
# prism-installer/tests/fixtures/deliberately-broken.ps1
# Always fails. Proves the gate is not vacuously green.
. (Join-Path $PSScriptRoot '..' 'shared' 'installer-common.ps1')
Assert-ExitCode -Expected @(0) -Actual 101 -Because 'this fixture is designed to fail'
- Step 2: Write the self-test
# prism-installer/test-runner-report.ps1
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
function Test-GateIsFailable {
# A gate that accepts a known-bad check is not gating.
$fixture = Join-Path $PSScriptRoot 'tests' 'fixtures' 'deliberately-broken.ps1'
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File $fixture 2>&1
$code = Get-NormalizedExitCode $LASTEXITCODE
if ($code -eq 0) { throw 'the gate accepted a known-bad check -- it is not gating' }
if (($out -join ' ') -notmatch 'contract violated') {
throw "the failure did not surface the contract message: $out"
}
Write-Host ' [PASS] the gate detects a known-bad check'
}
function Test-TallyIsTruthful {
# The shipped 2026-09-06 log: 7 failures reported as Passed: 3 / Failed: 0.
$log = Get-Content -Literal (Join-Path $PSScriptRoot 'tests' 'fixtures' 'false-green.log')
$realFails = @($log | Where-Object { $_ -match '^\s*\[FAIL\]' })
$claimed = @($log | Where-Object { $_ -match 'Failed:\s+0' -and $_ -notmatch '#'})
if ($realFails.Count -gt 0 -and $claimed.Count -gt 0) {
throw "$($realFails.Count) real [FAIL] lines were reported as Failed: 0 -- the tally is untrustworthy"
}
Write-Host " [PASS] tally matches the log ($($realFails.Count) failures accounted for)"
}
function Test-EmptySuiteIsRefused {
$out = & powershell -NoProfile -ExecutionPolicy Bypass -File `
(Join-Path $PSScriptRoot 'run-installer-tests.ps1') -Filter 'nonexistent-*' 2>&1
if (($out -join ' ') -match 'RUNNER_RESULT=PASS') {
throw 'an empty suite reported PASS -- a vacuous green'
}
Write-Host ' [PASS] an empty suite is refused, never a silent pass'
}
Test-GateIsFailable
Test-TallyIsTruthful
Test-EmptySuiteIsRefused
- Step 3: Run it
Run: pwsh -NoProfile -File prism-installer/test-runner-report.ps1
Expected: PASS (3 checks)
Then confirm the self-test is live: rename deliberately-broken.ps1 → .bak, re-run, and verify it turns red; restore.
- Step 4: Commit
git add prism-installer/test-runner-report.ps1 prism-installer/tests/fixtures/
git commit -m "test(ci): add a failable-gate self-test and an empty-suite guard"
Phase 2 — Static gates (fast, no build needed)¶
Task 5: Installer-script consistency analyzer (RG-001, RG-010)¶
Files:
- Create: prism-installer/static/analyze-install-consistency.ps1
Interfaces:
- Consumes: prism.iss, .github/workflows/build-release.yml.
- Produces: exit 0/1 and ##FAIL[] lines. Consumed by the workflow's Consistency job.
- Step 1: Write the analyzer
# prism-installer/static/analyze-install-consistency.ps1
[CmdletBinding(SupportsShouldProcess = 0)]
param([string] $Root = (Get-Location -Current))
$fail = @()
$iss = Join-Path $Root 'prism.iss'
$targets = @(
(Get-Item $iss),
(Get-ChildItem -Path $Root -Recurse -Include '*.ps1' -File -PathType 'Container'),
(Get-Content -Literal (Join-Path $Root '.github' 'workflows' 'build-release.yml') -Raw)
)
foreach ($t in $targets) {
$text = if ($t.PSIsContainer) { $t[0] } else { Get-Content -Literal $t.FullName -Raw }
# /VERYSILENT implies /NoRestart (never a 3010) and /Quiet (deprecated).
if ($text -match '/VERYSILENT' -and $text -notmatch '/NORESTART') {
$fail += "##FAIL $t - /VERYSILENT must be paired with /NORESTART [complexity=4]"
}
# The singular form is not a real Inno parameter: dialogs stay unsuppressed
# and a headless runner hangs. The working scripts use the plural; the docs
# did not (validated defect RG-001, mirrors the shipped installer-exit-codes.md).
if ($text -match '/SUPPRESSMSGBOX(?![ES])') {
$fail += "##FAIL $t - '/SUPPRESSMSGBOX' is not an Inno switch; use '/SUPPRESSMSGBOXES' (plural) or the check hangs a headless VM"
}
# Silent installs must write a log for post-mortem diagnosis.
if ($text -match '/VERYSILENT' -and $text -notmatch '/LOG') {
$fail += "##FAIL $t - silent installer runs must emit a per-test -Log"
}
}
# prism.iss [Code] is single-pass Pascal with no forward references.
$issText = Get-Content -Literal $iss -Raw
$codeStart = $issText.IndexOf('[Code]')
if ($codeStart -ge 0) {
$code = $issText.Substring($codeStart)
if ($code -match '(?m)^\s*;\s') {
$fail += "##FAIL prism.iss - ';' line comments are invalid inside [Code], use { } braces"
}
}
if ($fail.Count -gt 0) { $fail | ForEach-Object { Write-Host $_ }; exit 1 }
Write-Host '##[PASS] installer scripts are consistent'
exit 0
- Step 2: Run it and expect it to pass only once Task 8 lands
Run: pwsh -NoProfile -File prism-installer/static/analyze-install-consistency.ps1
Expected before the fix: FAIL — ##FAIL docs/getting-starting/installer-exit-codes.md - '/SUPPRESSMSGBOX' is not an Inno switch… (×3, RG-001 reproduced)
- Step 3: Commit
git add prism-installer/static/analyze-install-consistency.ps1
git commit -m "test(ci): add an installer-script consistency analyzer"
Task 6: Docs-drift gate (RG-004)¶
Files:
- Create: prism-installer/static/analyze-docs-drift.ps1
Interfaces:
- Consumes: docs/**/*.md, README.md, AGENTS.md, vagrant/scripts/tests/*.ps1.
- Produces: exit 0/1, ##FAIL[] lines.
- Step 1: Write it
# prism-installer/static/analyze-docs-drift.ps1
[CmdletBinding(SupportsShouldProcess = 0)]
param([string] $Root = (Get-Location -Current))
$fail = @()
$docs = @(Get-ChildItem -Path $Root -Recurse -Include '*.md' -File -PathType 'Leaf' `
-Exclude @('site/*', '.venv*/**')) + @(Get-Item (Join-Path $Root 'README.md'))
# Commands removed by PR #27 must not be documented or tested any longer.
$removedVerbs = @('prism terminal', 'iris -i', '--no-login')
foreach ($d in $docs) {
$text = Get-Content -Literal $d.FullName -Raw
foreach ($verb in $removedVerbs) {
if ($text -match [regex]::Escape($verb)) {
$fail += "##FAIL $d - documents '$verb' which was removed by #27"
}
}
# Stale counts: the shipped docs said "13 subcommands" before #27.
if ($text -match '\b1[0-9] subcommands\b' -and $text -notmatch 'no longer') {
$fail += "##FAIL $d - subcommand count looks stale, re-verify against prism --help"
}
# A documented flag that the shipped build never actually accepts.
if ($text -match '--static-analysis=(?!by-)(.+)') {
$fail += "##FAIL $d - references a flag no longer implemented (--$($matches[1]))"
}
}
# A renamed binary that stayed on the old name.
if (@(Get-ChildItem (Join-Path $Root 'dist') -Filter 'prism-*.exe' -File) |
Where-Object { $_.Name -notlike '*-setup.exe' }) {
$fail += "##FAIL dist - the standalone prism-<ver>.exe is gone, only prism-<ver>-setup.exe ships"
}
if ($fail.Count -gt 0) { $fail | ForEach-Object { Write-Host $_ }; exit 1 }
Write-Host '##[PASS] docs match the shipped surface'
exit 0
- Step 2: Run it
Expected before the fix: FAIL — 4 hits (RG-004: "13 subcommands" in 01-help.ps1, "Result: 1" in 13-test.ps1, "beta 2" + removed-binary in installation.md).
- Step 3: Commit
git add prism-installer/static/analyze-docs-drift.ps1
git commit -m "test(ci): add a docs-drift gate for the shipped installer surface"
Task 7: Build-payload gate (RG-010)¶
Files:
- Create: prism-installer/static/analyze-build-payload.ps1
- Step 1: Write it
# prism-installer/static/analyze-build-payload.ps1
# Flags dropped from the release build are the difference between a working
# installer and a broken cold start (observed: the shipped beta3 exe differs
# from beta2 only by the versioned binary).
[CmdletBinding(SupportsShouldProcess = 0)]
param([string] $Root = (Get-Location -Current))
$fail = @()
$wf = Get-Content -Literal (Join-Path $Root '.github' 'workflows' 'build-release.yml') -Raw
foreach ($flag in @('--icon', '--copy-metadata', '--collect-submodules', '--hidden-import')) {
if ($wf -notmatch [regex]::Escape($flag)) {
$fail += "##FAIL build-release.yml - required flag $flag is missing from the release build"
}
}
# --windowed suppresses the console: wrong for a CLI tool and can glitch the wizard.
if ($wf -match '--windowed') {
$fail += "##FAIL build-release.yml - --windowed must not be used for a CLI tool"
}
# The moving target: :latest breaks the pipeline with no code change on our side.
if ($wf -match 'iris-community:(?!2025\.3)') {
$fail += "##FAIL build-release.yml - the IRIS image tag must be pinned, never :latest"
}
if ($fail.Count -gt 0) { $fail | ForEach-Object { Write-Host $_ }; exit 1 }
Write-Host '##[PASS] the release build carries every required flag'
exit 0
-
Step 2: Run it — Expected: PASS today (the flags are present at
build-release.yml:183-199); deliberately delete--iconand re-run to confirm it reddens. -
Step 3: Commit
git add prism-installer/static/analyze-build-payload.ps1
git commit -m "test(ci): gate the release build on its required packaging flags"
Phase 3 — Runtime scenarios¶
Task 8: Fresh install (RG-001 success path)¶
Files:
- Create: prism-installer/test-install.ps1
Interfaces:
- Consumes: Invoke-PrismSilent, Assert-ExitCode, Assert-PrismInstalled, Remove-PrismInstall, $env:PRISM_OK.
- Produces: exit 0/1; ##FAIL RG-001 lines.
- Step 1: Write it
# prism-installer/test-install.ps1 - covers RG-001, RG-010
param([string] $ArtifactsPath = '.')
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$setup = Get-ChildItem -Path $ArtifactsPath -Filter 'prism-*-setup.exe' -File |
Select-Object -First 1
if ($null -eq $setup) {
Write-Host '##[SKIP] no installer was built (the build job failed or was skipped)'
exit 0
}
$version = (Get-Content -Literal (Join-Path $ArtifactsPath 'version.txt') -Raw).Trim()
Remove-PrismInstall # pre-clean: a stale key makes 100 ambiguous
Assert-PrismAbsent -Because 'a fresh install starts from a clean machine'
$dir = New-TempDir -Name 'install'
$log = Join-Path $dir 'install.log'
$code = Invoke-PrismSilent -Installer $setup.FullName -LogFilePath $log
Assert-ExitCode -Expected $env:PRISM_OK -Actual $code `
-Because 'a fresh silent install must succeed cleanly (0, never 100 with no prior registration)'
# The log is the post-mortem artifact: it must exist and report success.
if (-not (Test-Path -Literal $log)) { throw "no -Log was written to $log" }
if ((Get-Content -Literal $log -Raw) -notmatch 'Installation process succeeded') {
throw "the installer log does not report success -- inspect $log"
}
if ((Get-Content -Literal $log -Raw) -match 'return code [1-9]|Setup Failed') {
throw "the installer log records a failure -- inspect $log"
}
# A guard that the file count is the same one the build reported (no silent
# truncation, the same check that caught 1137 vs 1137 across the versions).
$expected = [int](Get-Content -Literal (Join-Path $ArtifactsPath 'file-count.txt') -Raw)
$actual = @(Get-ChildItem -Literal $env:InstallRoot -Recurse -File -Force).Count
if ($actual -ne $expected) { throw "installed $actual files, the build produced $expected" }
Assert-PrismInstalled -Version $version
- Step 2: Run it against a locally built installer
Run: pwsh -NoProfile -File prism-installer/test-install.ps1 -ArtifactsPath ./dist
Expected: PASS, then FAIL if you hand it 0.2.1-beta2 while version.txt says beta3 (proves the version assertion is live).
- Step 3: Commit
git add prism-installer/test-install.ps1
git commit -m "test(installer): cover the fresh silent install path"
Task 9: Upgrade matrix (RG-005, RG-008)¶
Files:
- Create: prism-installer/test-upgrade-matrix.ps1
Interfaces:
- Consumes: Invoke-PrismSilent, Assert-ExitCode with $env:PRISM_UPGRADE_OK, Assert-NoOrphan, Get-ChildItem, the downloaded baseline.
- Produces: exit 0/1; ##FAIL RG-005/##FAIL RG-008.
- Step 1: Write it
# prism-installer/test-upgrade-matrix.ps1 - covers RG-005, RG-008
param([string] $ArtifactsPath = '.')
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$setup = Get-ChildItem -Path $ArtifactsPath -Filter 'prism-*-setup.exe' -File | Select-Object -First 1
$old = Get-ChildItem -Path $ArtifactsPath -Filter 'prism-old-setup.exe' -File | Select-Object -First 1
if ($null -eq $setup) { Write-Host '##[SKIP] no installer built'; exit 0 }
if ($null -eq $old) { Write-Host '##[SKIP] no published baseline to upgrade from'; exit 0 }
$version = (Get-Content -Literal (Join-Path $ArtifactsPath 'version.txt') -Raw).Trim()
$dir = New-TempDir -Name 'upgrade'
Remove-PrismInstall
$baseline = Invoke-PrismSilent -Installer $old.FullName `
-LogFilePath (Join-Path $dir 'baseline.log')
Assert-ExitCode -Expected $env:PRISM_OK -Actual $baseline `
-Because 'the published baseline must install cleanly first'
$before = @(Get-ChildItem -Literal $env:InstallRoot -Recurse -File -Force | ForEach-Object { $_.Name })
# This is the scenario: run the NEW installer over an existing registration.
# GetCustomSetupExitCode returns 100 because WasPreviouslyInstalled is still
# true at InitializeSetup even though the files are replaced -- so 100 here is
# correct, and a gate asserting 0 would mis-read success as a failure (RG-005).
$code = Invoke-PrismSilent -Installer $setup.FullName -LogFilePath (Join-Path $dir 'upgrade.log')
Assert-ExitCode -Expected $env:PRISM_UPGRADE_OK -Actual $code `
-Because 'a re-install over a registered copy returns 100 by design, never 0'
Assert-PrismInstalled -Version $version
$after = @(Get-ChildItem -Literal $env:InstallRoot -Recurse -File -Force | ForEach-Object { $_.Name })
Assert-NoOrphan -Before $before -After $after
# Exactly one Prism_is1 entry, and it points at the NEW version: two AppIds
# would leave the two versions coexisting instead of one replacing the other
# (RG-008), the failure mode a "is prism.exe present" check cannot see.
$keys = @(Get-ChildItem -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' `
-ErrorAction SilentlyContinue |
Where-Object { (Get-ItemProperty -Path $_.PSPath -Name 'DisplayName' -ErrorAction SilentlyContinue) -like 'Prism*' })
if ($keys.Count -ne 1) { throw "$($keys.Count) Prism uninstall registrations found, expected exactly 1 (no side-by-side)" }
if ((Get-ItemProperty -Path $keys[0].PSPath -Name 'DisplayVersion') -notmatch [regex]::Escape($version)) {
throw 'the upgrade did not replace the versioned registration'
}
- Step 2: Run it locally with a hand-supplied baseline
Run: pwsh -NoProfile -File prism-installer/test-upgrade-matrix.ps1 -ArtifactsPath ./dist
Expected: PASS (exit 100, 0 orphans, 1 registration). Then assert the gate is live: edit the Assert-ExitCode -Expected to @(0) and confirm it FAILs with expected one of [0] but got 100 — that exact failure is RG-005 reproduced.
- Step 3: Commit
git add prism-installer/test-upgrade-matrix.ps1
git commit -m "test(installer): cover in-place upgrade over the published baseline"
Task 10: Uninstall cleanliness (RG-009)¶
Files:
- Create: prism-installer/test-uninstall.ps1
- Step 1: Write it
# prism-installer/test-uninstall.ps1 - covers RG-009
param([string] $ArtifactsPath = '.')
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$unins = Join-Path $env:InstallRoot 'unins000.exe'
if (-not (Test-Path -Literal $unins)) {
Write-Host '##[SKIP] nothing is installed to uninstall'
exit 0
}
$dir = New-TempDir -Name 'uninstall'
# /SILENT implies /VERYSILENT for the uninstaller too.
$proc = Start-Process -FilePath $unins `
-ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', "/LOG=$(Join-Path $dir 'uninstall.log')") `
-Wait -PassThru -WindowStyle Hidden
Assert-ExitCode -Expected $env:PRISM_OK -Actual $proc.ExitCode `
-Because 'the uninstaller must report a clean removal'
# Inno detaches; poll for up to 90s until prism.exe is gone, never a blind sleep.
$deadline = (Get-Date).AddSeconds(90)
while ((Get-Date) -lt $deadline -and (Test-Path -Literal (Join-Path $env:InstallRoot 'prism.exe'))) {
Start-Sleep -Milliseconds 1500
}
Assert-PrismAbsent -Because 'the uninstall completed'
-
Step 2: Run it — Expected: PASS after a Task 8 install; FAIL if you
Remove-Itemthe uninstaller first and leave the files (proves RG-009 is caught). -
Step 3: Commit
git add prism-installer/test-uninstall.ps1
git commit -m "test(installer): verify uninstall removes the exe, PATH entry and registry key"
Task 11: Exit-code contract (RG-006)¶
Files:
- Create: prism-installer/test-exit-code-contract.ps1
- Step 1: Write it
# prism-installer/test-exit-code-contract.ps1 - covers RG-006
param([string] $ArtifactsPath = '.')
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
# The automatable half: every code the runner can actually drive.
$matrix = @(
@{ Code = 0; Meaning = 'installation succeeded'; Guarnee = 'test-install.ps1' },
@{ Code = 100; Meaning = 'application already exists'; Guarantee = 'test-upgrade-matrix.ps1' },
@{ Code = 101; Meaning = 'invalid arguments (usage error)'; Guarantee = 'below' }
)
# Codes that need interactive input or a machine state the hosted runner cannot
# reproduce -> documented-but-not-exercised, skip-with-note, never a failure.
$notExercised = @(2, 3, 5, 6, 7, 8, 9)
function Invoke-PrismCli {
param([string[]] $Arguments)
$exe = Get-PrismVersion -ErrorAction SilentlyContinue
if ($null -eq $exe) {
$exe = Join-Path $env:InstallRoot 'prism.exe'
if (-not (Test-Path -Literal $exe)) { Write-Host '##[SKIP] prism is not installed'; exit 0 }
}
$p = Start-Process -FilePath $exe -ArgumentList $Arguments -Wait -PassThru `
-WindowStyle Hidden -ErrorAction SilentlyContinue -NoNewline
return (Get-NormalizedExitCode $LASTEXITCODE)
}
# Invalid flags exit 1 before anything reaches the installer.
foreach ($bad in @('--nonsense', '--bad-port=9999')) {
$code = Invoke-PrismCli -Arguments @($bad)
Assert-ExitCode -Expected @(1, 101) -Actual $code `
-Because "'prism $bad' is a usage error and must exit 1"
}
foreach ($code in $notExercised) {
# A guard that the matrix row is documented but not exercised.
Write-Host "##[SKIP] exit $code requires interactive input the hosted runner cannot supply"
}
# RG-006: the single-instance mutex row (exit 1) is not exercisable headlessly --
# two Start-Process -PassThru launches (even -WindowStyle Hidden) return an
# already-exited handle, so the mutex window never overlaps. Double-launch is
# corruption-safe (both runs reported a healthy 100), verified in
# test-concurrency.ps1 below and documented in installer-exit-codes.md.
Write-Host '##[SKIP] exit 1 (single-instance mutex) is not exercisable headlessly -- by design'
function Test-ConcurrencyIsCorruptionSafe {
$setup = Get-ChildItem -Path $ArtifactsPath -Filter 'prism-*-setup.exe' -File | Select-Object -First 1
if ($null -eq $setup) { Write-Host '##[SKIP] no installer to double-launch'; return }
$a = Invoke-PrismSilent -Installer $setup.FullName
$b = Invoke-PrismSilent -Installer $setup.FullName
foreach ($c in @($a, $b)) {
Assert-ExitCode -Expected $env:PRISM_UPGRADE_OK -Actual $c `
-Because 'concurrent installs of the same package must not corrupt each other'
}
}
Test-ConcurrencyIsCorruptionSafe
-
Step 2: Run it — Expected: PASS with 2
##[SKIP]lines. -
Step 3: Commit
git add prism-installer/test-exit-code-contract.ps1
git commit -m "test(installer): verify the documented exit-code contract"
Task 12: --help surface contract (RG-003)¶
Files:
- Create: prism-installer/test-help-contract.ps1
- Step 1: Write it
# prism-installer/test-help-contract.ps1 - covers RG-003
# The shipped 13-command list predated PR #27, which removed 'prism terminal'
# while the fixtures kept asserting it, so a shipped command went missing and CI
# stayed green. Assert against the real surface, never the old subset.
param([string] $ArtifactsPath = '.')
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$expected = @(
'config', 'sql', 'ws', 'compile', 'get-doc', 'list-docs', 'put-doc',
'delete-doc', 'info', 'test', 'list-tests', 'serve', 'setup', 'gui',
'chatbot', 'monitor', 'cast'
)
$exe = Join-Path $env:InstallRoot 'prism.exe'
if (-not (Test-Path -Literal $exe)) {
$exe = Join-Path $ArtifactsPath 'prism' 'prism.exe'
if (-not (Test-Path -Literal $exe)) { Write-Host '##[SKIP] no prism binary available'; exit 0 }
}
$help = (& $exe --help 2>&1) | Out-String
if ($LASTEXITCODE -ne 0) { throw "prism --help exited $LASTEXITCODE" }
foreach ($verb in $expected) {
if ($help -notmatch ('(?m)^\s{2}' + [regex]::Escape($verb) + '\s')) {
throw "the shipped surface lost '$verb' -- update the contract or restore the command"
}
}
# The removed command must STAY gone: its fixtures are the reason 7 false
# failures were reported as PASS in the 2026-09-06 rehearsal.
if ($help -match '(?m)^\s{2}terminal\s') {
throw "'terminal' must not be advertised -- it was removed by #27"
}
Write-Host " [PASS] all $($expected.Count) subcommands are advertised, 'terminal' stays gone"
-
Step 2: Run it — Expected before the repair: FAIL (
Error: No such command 'terminal', exit 2 wave, RG-003 reproduced). After: PASS, 17/17. -
Step 3: Commit
git add prism-installer/test-help-contract.ps1
git commit -m "test(ci): assert the --help surface so shipped commands cannot go missing"
Phase 3b — Baseline acquisition and the version-boundary scenarios¶
These three tasks exist because the upgrade story is only trustworthy if the inputs are chosen deterministically and the mechanism is exercised in both directions of version ordering. Tasks 8/9 prove install and upgrade for whatever pair CI happens to pick; these pin that choice and add the two boundaries a plain upgrade test cannot reach: a higher version that has never shipped (Task 14), and an older installer over a newer one (Task 15).
Task 13: Deterministic baseline acquisition (RG-012, RG-013)¶
Files:
- Create: prism-installer/test-prerelease.ps1
- Modify: .github/workflows/installer-verification.yml (the Fetch the published baseline step, replaced in Task 17)
Interfaces:
- Consumes: gh api, gh release download, -Repo, -SourceVersion.
- Produces: dist/prism-old-setup.exe, dist/baseline-tag.txt, dist/baseline-sha256.txt — read by Tasks 9, 14 and 15. Exit 0/1; ##[FAIL] RG-012 / RG-013 lines.
Two defects close here. RG-012: /releases/latest excludes pre-releases, and on this repo every release since v0.2.1 (v0.2.2-beta1 … beta7) is prerelease=true — measured: that endpoint returns the stale v0.2.1 while the newest is v0.2.2-beta7. Pinning the baseline there makes the matrix silently test a two-releases-back neighbor, and the day a stable release ships without a setup.exe asset the baseline vanishes and the upgrade matrix runs zero scenarios while still reporting green — the RG-007 vacuous-green class arriving through a second door. RG-013: --pattern is a glob, not a regex, confirmed on gh 2.46.0 where * and an exact name match but .*setup\.exe$, .setup. and .* each print no assets match the file pattern and exit 1. A miss therefore reads as a clean run unless the exit code is asserted, and the plan's best-effort exit 0 wrapper would turn it into a silent skip.
- Step 1: Write it
# prism-installer/test-prerelease.ps1 - covers RG-012, RG-013
# Acquires the published installer the upgrade matrix will upgrade FROM.
param(
[Parameter(Mandatory = $true)][string] $Repo, # 'owner/name', from github.repository
[Parameter(Mandatory = $true)][string] $SourceVersion,
[string] $OutDir = 'dist',
# Newest overall by default, matching how Prism actually ships (tags land on
# dev as pre-releases). The tightest delta is the point; stable-only is the
# opt-in fallback, precisely because /releases/latest hides the pre-releases.
[switch] $StableOnly = $false
)
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$ErrorActionPreference = 'Stop'
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) {
Write-Host '##[SKIP] RG-012 gh is not installed; the published baseline cannot be acquired'
exit 0
}
if ($Repo -notmatch '^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$') { throw "invalid -Repo '$Repo' (expected 'owner/name')" }
# The full list, not /releases/latest. GitHub returns it newest-first by
# created_at, so the array order is already the precedence order -- do NOT
# re-sort by [version] tag, which throws on the '0.2.2-beta7' form.
$releases = (gh api "repos/$Repo/releases" --jq '[.[] | select(.draft == false)]' |
ConvertFrom-Json)
if ($LASTEXITCODE -gt 1) { Write-Host "##[FAIL] RG-012 gh api failed (exit $LASTEXITCODE)"; exit 1 }
if (-not $releases) {
Write-Host '##[SKIP] RG-012 the repository has no releases yet'
exit 0
}
# Walk newest -> oldest; keep the first that carries the asset and is not this build.
$candidates = $releases
if ($StableOnly) { $candidates = $candidates | Where-Object { $_.prerelease -eq $false } }
if (-not $candidates) {
Write-Host "##[FAIL] RG-012 no release qualifies under -StableOnly='$StableOnly'"
exit 1
}
$picked = $null
foreach ($release in $candidates) {
$tag = $release.tag_name
if ($tag -eq ('v' + $SourceVersion) -or $tag -eq $SourceVersion) { continue } # never the build itself
$asset = $release.assets | Where-Object { $_.name -like '*-setup.exe' } |
Select-Object -First 1
if ($asset) { $picked = @{ Tag = $tag; Asset = $asset }; break }
}
if (-not $picked) {
Write-Host '##[SKIP] RG-012 no published release carries a setup.exe asset (the first release has nothing to upgrade from)'
exit 0
}
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$target = Join-Path $OutDir 'prism-old-setup.exe'
if (Test-Path -Literal $target) { Remove-Item -Literal $target -Force } # always re-download; a stale file masks a moved upload
# RG-013: --pattern is a GLOB and is never quoted. Assert the download landed
# and is a real payload -- a 0-asset or truncated baseline must fail, not skip.
& gh release download $picked.Tag --repo $Repo --clobber --dir $OutDir `
--pattern '*-setup.exe'
if ($LASTEXITCODE -gt 0) {
Write-Host "##[FAIL] RG-013 gh release download $($picked.Tag) exited $LASTEXITCODE with no matching asset"
exit 1
}
$landed = Get-ChildItem -Literal $OutDir -Filter '*-setup.exe' -File | Select-Object -First 1
if (-not $landed) {
Write-Host "##[FAIL] RG-013 $($picked.Tag) lists a setup.exe asset but the download wrote none"
exit 1
}
if ($landed.Length -lt 1MB) {
Write-Host "##[FAIL] RG-013 the downloaded baseline is only $($landed.Length) bytes"
exit 1
}
Move-Item -Literal $landed.FullName -Destination $target -Force
Set-Content -Literal (Join-Path $OutDir 'baseline-tag.txt') -Value $picked.Tag
Set-Content -Literal (Join-Path $OutDir 'baseline-sha256.txt') `
-Value (Get-FileHash -Literal $target -Algorithm SHA256).Hash.ToLower()
Write-Host ("##[PASS] baseline " + $picked.Tag + " acquired from " + $Repo)
exit 0
- Step 2: Run it against this repository —
-Repo AdriaERNI/Prism -SourceVersion 0.2.2-beta7→##[PASS] baseline …, and the tag it names must be a release newer thanv0.2.1unless-StableOnlywas passed (that downgrade is the caller's deliberate choice, never the default). - Step 3: Prove both guards bite — point the asset filter at a name no release publishes →
##[FAIL] RG-013 … wrote none, exit1, never green. Delete every release in a throwaway fork →##[SKIP] … no releases yet, exit0. - Step 4: Commit —
git add prism-installer/test-prerelease.ps1 && git commit -m "test(ci): make the upgrade baseline deterministic and refuse silent misses"
Task 14: Future-version upgrade (the never-shipped case) (RG-014)¶
Files:
- Create: prism-installer/test-future-version.ps1
Interfaces:
- Consumes: New-PrismSetup (the shared ISCC builder — defined once in Task 1; every compile in the suite goes through it, never a bare & $iscc), Get-NextVersion, Get-PrismFileVersion, Get-PrismRegistryPath, Get-ItemProperty, Assert-PrismInstalled, Remove-PrismInstall, New-TempDir, Invoke-PrismSilent, Assert-ExitCode, the acquired baseline from Task 13.
- Produces: exit 0/1; ##[FAIL] RG-014 lines.
This is the scenario a release bump cannot reach on its own and the one the
user asked for: rename the version to something higher and install it over
the current one. It proves the upgrade mechanism generalises past the single
version CI happens to be building, and that a tag which has never existed as a
download still upgrades cleanly — the real path for a vX.Y.Z release that is
not on releases yet. The synthetic build reuses the real prism.exe
payload and overrides only AppVersion / AppName / the RTF, so the artifact
under test stays the genuine compiled binary with one field changed. The
version is derived from Get-Content pyproject.toml, never hardcoded — the
same # agent: must never that applies to every test here.
- Step 1: Write it
# prism-installer/test-future-version.ps1 - covers RG-014
# Installs a synthetic HIGHER version over the real one: proves the upgrade
# path generalises beyond the one version CI happens to be building.
param(
[string] $ArtifactsPath = '.',
[int] $Keep = 2,
[int] $TimeoutSec = 300
)
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
# Never a hardcoded future number here -- the number is derived below.
$version = (Get-Content -Literal 'pyproject.toml' -Raw)
if ($version -notmatch '(?m)^\s*version\s*=\s*"([^"]+)"') {
throw 'pyproject.toml has no version = "x.y.z" line to derive a future version from'
}
$real = $matches[1]
$higher = Get-NextVersion -Version $real -Bump patch -Direction up # 0.2.2-beta7 -> 0.2.3; never a literal
foreach ($v in @($real, $higher)) {
if ($v -notmatch '^\d+\.\d+(\.\d+)?$') { throw "derived version '$v' is not a plain X.Y.Z" }
}
Remove-PrismInstall # start from a clean machine
$setup = Get-ChildItem -Literal $ArtifactsPath -Filter 'prism-*-setup.exe' -File | Select-Object -First 1
if (-not $setup) { Write-Host '##[SKIP] RG-014 no built installer to upgrade'; exit 0 }
# Real payload, only the version fields overridden -- the binary is untouched.
# New-PrismSetup is the ONE compile path (Task 1) -- it takes the repo root, the
# out directory and the .iss explicitly (a mandatory param, no default), the same
# three the release build passes, so the synthetic build cannot drift from the real one.
$future = New-PrismSetup -Version $higher -SourcePath (Join-Path $repoRoot 'dist\prism') `
-OutDirectory (Join-Path $repoRoot 'ut\future') -IssPath (Join-Path $repoRoot 'prism.iss')
foreach ($p in @($setup.FullName, $future)) {
if (-not (Test-Path -Literal $p)) { throw "missing generated setup at '$p' (build it first)" }
}
# 1. the real build lands
$log = Join-Path (New-TempDir -Name 'future-real') 'real.log'
Assert-ExitCode -Expected @([int]$env:PRISM_OK) -Actual (Invoke-PrismSilent -Installer $setup.FullName -LogFilePath $log) `
-Because 'the base version must install before it can be upgraded'
# 2. the never-published higher version installs OVER it -- exit 0 or 100.
$up = Join-Path (Split-Path $log -Parent) 'upgrade.log'
Assert-ExitCode -Expected $env:PRISM_UPGRADE_OK -Actual (Invoke-PrismSilent -Installer $future.FullName -LogFilePath $up) `
-Because "v$higher is not published anywhere, so the upgrade must still complete"
# 3. the machine ends on the future version, with no orphan and one registration.
# Assert-PrismInstalled is the same gate the fresh-install scenario uses, so the
# two cannot disagree about what 'installed' means.
Assert-PrismInstalled -Version $higher -Because 'the higher version must win'
# File count comes from the payload directory the build consumed -- never a
# hardcoded literal, and never a count taken from the directory being written to.
$expectedFileCount = @(Get-ChildItem -Literal (Join-Path $repoRoot 'dist\prism') -Recurse -File -Force).Count
$installedFiles = @(Get-ChildItem -Literal $env:InstallRoot -Recurse -File -Force)
if ($installedFiles.Count -ne $expectedFileCount) {
Write-Host "##[FAIL] RG-014 the future-version upgrade left an orphan (installed $($installedFiles.Count), payload $expectedFileCount)"; exit 1
}
# Duplicate registrations: the count must be taken the same way the old measurement
# did, against the machine scope (the pre-upgrade $about baseline), never the
# per-user scope which the installer does not touch.
$registered = @(Get-PrismRegistryPath -Scope Machine)
if ($registered.Count -ne 1) {
Write-Host "##[FAIL] RG-014 the future-version upgrade left duplicate registrations (found $($registered.Count), expected 1)"; exit 1
}
# 4. retention -- the scratch dir keeps the last $Keep builds. The most recent build
# is always retained so a re-run can compare against it.
$keepRoot = Join-Path $ArtifactsPath 'future'
$futures = @(Get-ChildItem -Literal $keepRoot -Directory -ErrorAction SilentlyContinue) |
Sort-Object LastWriteTime -Descending
if ($futures.Count -gt $Keep) {
$futures | Select-Object -Skip $Keep | Remove-Item -Recurse -Force -Confirm:$false
}
# 5. the synthetic build must differ from the real one -- the version resource is the
# thing that changed, so compare THAT (not a whole-file hash, which an embedded
# PE timestamp makes equal on every run, and not a self-comparison of one value).
$realVersion = (Get-PrismFileVersion -LiteralPath $setup.FullName).ToString()
$nextVersion = (Get-PrismFileVersion -LiteralPath $future).ToString()
if ($realVersion -eq $nextVersion) {
Write-Host "##[FAIL] RG-014 the future build carries the real version $nextVersion (the override was not applied)"; exit 1
}
if ($nextVersion -notmatch ('^' + [regex]::Escape($higher))) {
Write-Host "##[FAIL] RG-014 the future build reports $nextVersion, expected $higher"; exit 1
}
Write-Host "##[PASS] RG-014 $real -> synthetic $higher (never published) upgrades cleanly"
exit 0
- Step 2: Run it — Expected: exit
0, and the log showsDisplayVersionmoving to$higher. Negative control: force the version override to be a no-op — the build must then be rejected as byte-identical (the step-5 guard), never pass. - Step 3: Confirm no stale pin —
grep -nE '"[0-9]+\.[0-9]+\.[0-9]+"' prism-installer/test-future-version.ps1returns nothing but the derived-from-pyproject.tomllines. - Step 4: Commit —
git add prism-installer/test-future-version.ps1 && git commit -m "test(ci): upgrade to a synthetic never-published version"
Task 15: Downgrade refusal (the guard in the other direction) (RG-015)¶
Files:
- Create: prism-installer/test-downgrade.ps1
Interfaces:
- Consumes: New-PrismSetup, Get-NextVersion, Get-PrismFileVersion, Get-PrismRegistryPath, Get-ItemProperty, Invoke-PrismSilent, Assert-PrismInstalled, Remove-PrismInstall, New-TempDir, the real setup.exe.
- Produces: exit 0/1; ##[FAIL] RG-015 lines.
prism.iss:85 installs with Flags: ignoreversion, which makes Setup overwrite
a newer file without asking — so an older installer dropped onto a newer one
succeeds silently, and Windows records the downgrade as a normal upgrade. This
is the guard for that: it pins the contract so a future change to [Files]
(drop ignoreversion, or add promptifolder) cannot quietly turn a downgrade
into a half-applied tree where the new files stay on disk behind an old version
number. It is a guard test, so the expected outcome is asserted against the
current behaviour — an intentional downgrade is allowed to proceed and must
leave a consistent single registration, never a mix.
- Step 1: Write it
# prism-installer/test-downgrade.ps1 - covers RG-015
# Guard: an OLDER setup installed over a NEWER one must stay consistent.
param([string] $ArtifactsPath = '.', [int] $TimeoutSec = 300)
. (Join-Path $PSScriptRoot 'shared' 'installer-common.ps1')
$setup = Get-ChildItem -Literal $ArtifactsPath -Filter 'prism-*-setup.exe' -File | Select-Object -First 1
if (-not $setup) { Write-Host '##[SKIP] RG-015 no built installer'; exit 0 }
$version = (Get-Content -Literal (Join-Path $ArtifactsPath 'version.txt') -Raw).Trim()
if ($version -notmatch '^\d+\.\d+\.\d+$') { throw "expected the shape of a dotted version" }
$lower = Get-NextVersion -Version $version -Bump patch -Direction down # never a literal
Remove-PrismInstall
# Install the NEWER build first, so the downgrade is genuinely a downgrade.
Assert-ExitCode -Expected @([int]$env:PRISM_OK) -Actual (Invoke-PrismSilent -Installer $setup.FullName) `
-Because 'the newer version must be present before an older one can replace it'
Assert-PrismInstalled -Version $version
# The older setup is built from the same real payload with only the version lowered.
$older = New-PrismSetup -Version $lower -SourcePath (Join-Path $repoRoot 'dist\prism') `
-OutDirectory (Join-Path $repoRoot 'ut\downgrade') -IssPath (Join-Path $repoRoot 'prism.iss')
$dir = New-TempDir -Name 'downgrade'
Assert-ExitCode -Expected $env:PRISM_UPGRADE_OK -Actual (Invoke-PrismSilent -Installer $older.FullName `
-LogFilePath (Join-Path $dir 'downgrade.log')) `
-Because 'ignoreversion makes Setup overwrite the newer files without asking'
# Consistency, not just success: the tree must match the OLDER manifest exactly.
$expectedFileCount = @(Get-ChildItem -Literal (Join-Path $repoRoot 'dist\prism') -Recurse -File -Force).Count
$installedFiles = @(Get-ChildItem -Literal $env:InstallRoot -Recurse -File -Force)
if ($installedFiles.Count -ne $expectedFileCount) {
Write-Host "##[FAIL] RG-015 the downgrade left the newer files behind (installed $($installedFiles.Count), expected $expectedFileCount)"; exit 1
}
# The registry value the same way the old measurement did, against the machine scope.
$registered = @(Get-PrismRegistryPath -Scope Machine)
if ($registered.Count -ne 1) {
Write-Host "##[FAIL] RG-015 the downgrade left duplicate registrations (found $($registered.Count))"; exit 1
}
if ((Get-ItemProperty -Literal $env:UninstallKey -Name 'DisplayVersion' -ErrorAction SilentlyContinue) -ne $lower) {
Write-Host "##[FAIL] RG-015 the registry still reports $(Get-ItemProperty -Literal $env:UninstallKey -Name 'DisplayVersion') after a downgrade"; exit 1
}
Write-Host "##[PASS] RG-015 $version -> $lower stays consistent (one registration, no stale files)"
exit 0
- Step 2: Run it — Expected: exit
0,DisplayVersionequals the fixture version, no orphan files. - Step 3: Prove the pin holds — remove
Flags: ignoreversionfromprism.iss:85; the run must turn red withthe downgrade left the newer files behind. Restore it. - Step 4: Commit —
git add prism-installer/test-downgrade.ps1 && git commit -m "test(ci): guard downgrade consistency under ignoreversion"
Phase 4 — The workflow + repair push¶
Task 16: Repair the stale fixtures and docs¶
Files:
- Delete: vagrant/scripts/tests/05-terminal.ps1
- Modify: vagrant/scripts/tests/01-help.ps1, vagrant/scripts/tests/06-ws.ps1, vagrant/scripts/tests/13-test.ps1
- Modify: docs/getting-starting/installer-exit-codes.md, docs/getting-starting/installation.md
Interfaces: - Consumes: the gates from Tasks 5-12 as the pass/fail oracle. - Produces: a green Vagrant baseline (17 suites / 87 checks / 0 fail) and 0 hits from both static analyzers.
- Step 1: Ground-truth the removal first — never "fix" green code to satisfy a dead test
git show 2e3661a --stat | grep -i terminal # PR #27 deleted the command
grep -rn "prism terminal\|'terminal'\|no longers" --include=*.ps1 .
-
Step 2: Delete
05-terminal.ps1(its 4 shapes duplicate06-ws.ps1), fold its two unique cases ($ZVersionstring, syntax-error reporting) into06-ws.ps1, and re-point01-help.ps1at the real 17-command surface with amust not be advertisedguard. -
Step 3: Fix the docs —
/SUPPRESSMSGBOX→/SUPPRESSMSGBOXES(3×,installer-exit-codes.md:141,150,163), "13 subcommands" → 17, drop theResult: 1comment in13-test.ps1, and the "beta 2" / removed-binary note ininstallation.md. -
Step 4: Verify the gates go green
Run: pwsh -NoProfile -File prism-installer/static/analyze-install-consistency.ps1 && pwsh -NoProfile -File prism-installer/static/analyze-docs-drift.ps1 && pwsh -NoProfile -File prism-installer/test-help-contract.ps1
Expected: all three ##[PASS], exit 0 (this is the green half of the red→green demonstration)
- Step 5: Commit
git add -A vagrant/scripts/tests docs/getting-starting
git commit -m "test(vagrant): repair stale fixtures left by the terminal removal"
Task 17: The workflow¶
Files:
- Create: .github/workflows/installer-verification.yml
Interfaces:
- Consumes: run-installer-tests.ps1, prism.iss, the published release assets, build-release.yml's exact build flags.
- Produces: the Installer Verification / verify (windows-latest) required status check.
- Step 1: Write it
name: Installer Verification
on:
push:
branches: [main, development]
pull_request:
branches: [main, development]
workflow_dispatch:
concurrency:
group: installer-${{ github.ref }}
cancel-in-progress: true
jobs:
consistency:
name: Consistency (static, no build needed)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Installer-script consistency
shell: pwsh
run: pwsh -NoProfile -File prism-installer/static/analyze-install-consistency.ps1
- name: Docs drift
shell: pwsh
run: pwsh -NoProfile -File prism-installer/static/analyze-docs-drift.ps1
- name: Build payload
shell: pwsh
run: pwsh -NoProfile -File prism-installer/static/analyze-build-payload.ps1
build:
name: Build installer (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest]
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- run: uv sync --frozen
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
# Mirrors build-release.yml exactly: the same PyInstaller invocation and
# the same ISCC defines, so CI proves the shipped artefact, not a proxy.
- name: Build PyInstaller onedir
shell: pwsh
run: |
uv pip install pyinstaller
uv run pyinstaller --onedir --name prism --icon logo.ico `
--copy-metadata fastmcp --copy-metadata click --copy-metadata httpx `
--copy-metadata pydantic --copy-metadata pydantic-settings `
--copy-metadata typer --copy-metadata websockets `
--copy-metadata platformdirs --copy-metadata python-dotenv `
--copy-metadata toons --collect-all lupa --collect-all docket `
--collect-all shellingham --collect-submodules prism.mcp `
--hidden-import importlib.metadata src/prism/cli/app.py
- name: Compile the installer
shell: pwsh
run: |
$version = (uv run python -c "import prism; print(prism.__version__)").Trim()
$numeric = ($version -split '-')[0]
$iscc = Get-ChildItem 'C:\Program Files*\Inno Setup*\ISCC.exe' | Select-Object -First 1
& $iscc.FullName "/DAppVer=$numeric.0" "/DAppVerFile=$version" `
"/DAppSource=dist\prism" prism.iss
Set-Content -Literal dist\version.txt -Value $version
@(Get-ChildItem dist\prism -Recurse -File).Count |
Set-Content -Literal dist\file-count.txt
# The upgrade baseline is the real published artefact, not a local rebuild,
# so the matrix proves a genuine old -> new path. Best-effort: a repo with
# no prior release, or a release with no setup asset, must skip not fail.
- name: Fetch the published baseline
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$tag = (gh api 'repos/{owner}/{repo}/releases/latest' --jq '.tag_name')
$asset = (gh api "repos/{owner}/{repo}/releases/tags/$tag/assets" --jq '.[0].name')
if (-not $tag -or -not $asset) { Write-Host 'no published baseline; upgrade will skip'; exit 0 }
gh release download $tag --clobber --pattern 'prism-*-setup.exe'
if ($LASTEXITCODE -ne 0) { Write-Host 'baseline asset unavailable; upgrade will skip'; exit 0 }
Get-ChildItem . -Filter '*-setup.exe' -File | Select-Object -First 1 |
ForEach-Object { Copy-Item $_.FullName dist\prism-old-setup.exe -Force }
- uses: actions/upload-artifact@v7
with:
name: installer-${{ matrix.os }}-${{ github.run_id }}
path: |
dist/prism-*-setup.exe
dist/version.txt
dist/file-count.txt
retention-days: 30
if: always()
verify:
name: verify (${{ matrix.os }})
needs: [consistency, build]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest]
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v7
with:
name: installer-${{ matrix.os }}-${{ github.run_id }}
path: dist
# Pre-flight: a broken check must redden the job before the real suite runs.
- name: Gate is live (negative self-test)
shell: pwsh
run: pwsh -NoProfile -File prism-installer/test-runner-report.ps1
- name: Run the installer suite
shell: pwsh
run: pwsh -NoProfile -File prism-installer/run-installer-tests.ps1 -ArtifactsPath ./dist
- name: Upload scenario logs
if: always()
uses: actions/upload-artifact@v7
with:
name: installer-logs-${{ matrix.os }}-${{ github.run_id }}
path: prism-installer/tests/temp-*/
retention-days: 30
-
Step 2: Lint the YAML locally —
yamllint .github/workflows/installer-verification.yml→ 0 errors; andactionlint .github/workflows/if installed. -
Step 3: Register the required status check (never weaken
enforce_admins):
gh api repos/AdriaERNI/Prism/branches/development/protection/required_status_checks \
-X PUT -H "Accept: application/vnd.github+json" --input - \
<<< '[{"context":"Installer Verification / verify (windows-latest)","integration_id":15368,"integration_type":"github_app"}]'
- Step 4: Commit
git add .github/workflows/installer-verification.yml
git commit -m "ci(installer): gate the installer on a real Windows runner"
Task 18: Vagrant parity + documentation¶
Files:
- Modify: vagrant/run-tests.ps1 (or the documented invocation in AGENTS.md)
- Modify: AGENTS.md — protected file, needs user approval to edit
-
Step 1: Point the Vagrant entry point at the committed suite (
-ArtifactsPath) so the VM and CI execute the identical scenario set — the parity check "must run under the same harness the CI runs, no drift". -
Step 2: Document in
AGENTS.md: the newInstaller Verificationgate, the exit-code contract table, and the "100 on upgrade is success, never 0" rule. -
Step 3: Commit
git add vagrant/run-tests.ps1 AGENTS.md
git commit -m "docs(ci): document the installer verification gate and exit-code contract"
Two-push execution plan (red → green)¶
Per the user's chosen flow: the fixes stay local until the reproducer is proven in CI.
| Push | Contents | CI must show |
|---|---|---|
| 1 — reproducer | Tasks 1-12 + 14-15 + 17 (harness, static gates, runtime scenarios, workflow). Tasks 3 & 16 withheld. | RED: Consistency ✗ (RG-001 ×3, RG-004 ×4), verify ✗ (test-help-contract exit-2 wave RG-003, test-runner-report RG-002, test-upgrade-matrix RG-005/008 if the baseline is wrong), Build ✓, Test Windows ✓ — i.e. real failures, not infrastructure noise |
| 2 — fix | Tasks 3, 16 (fixture/doc repairs) + the _common.ps1 tally fix |
GREEN: 0 [FAIL], RESULT: PASS, 17/17 subcommands, upgrade exits 0,100, ##[SKIP] only for the documented RG-006 rows |
Both pushes target a new branch (ci/installer-verification) opened against development — never main, never release/*, never vagrant/*.
Verification¶
-
pwsh -NoProfile -File prism-installer/run-installer-tests.ps1 -Filter 'nonexistent-*'→ exit1,RESULT: FAIL(RG-007 cannot recur). -
pwsh -NoProfile -File prism-installer/test-runner-report.ps1→ PASS (3 checks), and red whendeliberately-broken.ps1is hidden (the gate is live). -
pwsh -File prism-installer/static/analyze-install-consistency.ps1→ exit1before Task 16,##[PASS]after (RG-001). -
pwsh -File prism-installer/static/analyze-docs-drift.ps1→ exit1before Task 16,##[PASS]after (RG-004). -
pwsh -File prism-installer/test-help-contract.ps1→ exit2-wave before Task 16, 17/17 after (RG-003). - Fresh install → exit
0,[PASS] installed,prism --versioncorrect, one PATH entry, onePrism_is1key. - Upgrade over the published baseline → exit
0/100,##[PASS] no orphans, exactly one registration (RG-005/RG-008). - Uninstall → exit
0,Assert-PrismAbsentclean (RG-009). - Existing suite unmodified and still green:
PYTHONPATH="" uv run pytest tests/unit tests/mcp -q,uv run ruff check . && uv run ruff format --check .,uv run mkdocs build --strict. - Vagrant baseline re-confirm: 17 suites / 87 checks / 0 fail.
Assumptions¶
windows-latestis the runner (GitHub-hosted, real Windows, admin, no display, no Docker → no live IRIS, hence every IRIS-backed check is-SkipIris/##[SKIP]).- The upgrade baseline is the latest published release's
prism-*-setup.exe(v0.2.2-beta7 currently ships one). 1/2/3/5/6/7/8/9are documented-but-not-exercised by design on a headless runner.