From a165e96eb682a61c7224604724224cfc334227b3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 11 Jul 2026 19:23:42 +0200 Subject: [PATCH 1/3] Remove other loaded module versions before importing the selected one Install-PSResourceWithRetry now removes any already-loaded versions of the module from the session before importing the resolved version, so the selected version is the only one loaded (not a side-by-side or runner-preinstalled copy). --- src/Invoke-Pester.Helpers.psm1 | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Invoke-Pester.Helpers.psm1 b/src/Invoke-Pester.Helpers.psm1 index 300f0c3e..7003c24d 100644 --- a/src/Invoke-Pester.Helpers.psm1 +++ b/src/Invoke-Pester.Helpers.psm1 @@ -1283,14 +1283,14 @@ function Invoke-ProcessTestDirectory { function Install-PSResourceWithRetry { <# .SYNOPSIS - Installs a PowerShell module with a retry mechanism, then imports the installed version. + Installs a PowerShell module with a retry mechanism, then imports the installed version as the only loaded version. .DESCRIPTION Attempts to install a PowerShell module multiple times in case of failure. When a version - constraint is supplied, the newest version satisfying it is installed; that exact version is - then imported so the loaded module is deterministic even when other versions (for example the - runner's preinstalled copy) are present on the machine. When no version is supplied, the latest - available version is installed. + constraint is supplied, the newest version satisfying it is installed. Any other versions already + loaded in the session are then removed and that exact version is imported, so the loaded module is + deterministic even when other versions (for example the runner's preinstalled copy) are present on + the machine. When no version is supplied, the latest available version is installed. #> [CmdletBinding()] param ( @@ -1373,9 +1373,11 @@ function Install-PSResourceWithRetry { $resolved = Get-InstalledPSResource @getParams | Sort-Object Version -Descending | Select-Object -First 1 } - # Import into the global session state so the resolved version is the one every subsequent + # Remove any already-loaded versions from the session so the chosen version is the only one loaded, + # then import into the global session state so the resolved version is the one every subsequent # command (for example Invoke-Pester in exec.ps1) uses, instead of PowerShell auto-loading the # highest version available on PSModulePath. + Remove-Module -Name $Name -Force -ErrorAction SilentlyContinue if ($resolved) { Write-Output "Importing module: $Name $($resolved.Version)" $imported = Import-Module -Name $Name -RequiredVersion $resolved.Version -Force -Global -PassThru -ErrorAction Stop From ae28ca2039ab6a976e0d017ab743fe714fb7bf20 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 12 Jul 2026 11:59:54 +0200 Subject: [PATCH 2/3] Address review: remove all loaded module instances and make resolution prerelease-aware Per PR review: use 'Get-Module -Name \ -All | Remove-Module' so every loaded instance (all versions, including nested) is removed before import; and when Prerelease is not requested, exclude prerelease candidates from resolution and add a stable-wins tie-breaker so a prerelease is never selected over a stable of the same base version. --- src/Invoke-Pester.Helpers.psm1 | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/Invoke-Pester.Helpers.psm1 b/src/Invoke-Pester.Helpers.psm1 index 7003c24d..75bd95b7 100644 --- a/src/Invoke-Pester.Helpers.psm1 +++ b/src/Invoke-Pester.Helpers.psm1 @@ -1363,21 +1363,30 @@ function Install-PSResourceWithRetry { # Resolve the exact version to import. Prefer what was just installed; if the resource was # already present, Install-PSResource returns nothing, so fall back to the newest installed - # version that satisfies the requested constraint. - $resolved = $installed | Where-Object { $_.Name -eq $Name } | Sort-Object Version -Descending | Select-Object -First 1 - if (-not $resolved) { + # version that satisfies the requested constraint. When prerelease versions are not requested, + # never resolve to one; when a stable and a prerelease share a base version, the stable one wins. + $sortOrder = @( + @{ Expression = 'Version'; Descending = $true } + @{ Expression = 'IsPrerelease'; Descending = $false } + ) + $candidates = @($installed | Where-Object { $_.Name -eq $Name }) + if ($candidates.Count -eq 0) { $getParams = @{ Name = $Name; Verbose = $false; ErrorAction = 'SilentlyContinue' } if (-not [string]::IsNullOrWhiteSpace($Version)) { $getParams['Version'] = $Version } - $resolved = Get-InstalledPSResource @getParams | Sort-Object Version -Descending | Select-Object -First 1 + $candidates = @(Get-InstalledPSResource @getParams) } + if (-not $Prerelease) { + $candidates = @($candidates | Where-Object { -not $_.IsPrerelease }) + } + $resolved = $candidates | Sort-Object -Property $sortOrder | Select-Object -First 1 - # Remove any already-loaded versions from the session so the chosen version is the only one loaded, - # then import into the global session state so the resolved version is the one every subsequent - # command (for example Invoke-Pester in exec.ps1) uses, instead of PowerShell auto-loading the - # highest version available on PSModulePath. - Remove-Module -Name $Name -Force -ErrorAction SilentlyContinue + # Remove every already-loaded instance (all versions, including nested) from the session so the + # chosen version is the only one loaded, then import into the global session state so the resolved + # version is the one every subsequent command (for example Invoke-Pester in exec.ps1) uses, instead + # of PowerShell auto-loading the highest version available on PSModulePath. + Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue if ($resolved) { Write-Output "Importing module: $Name $($resolved.Version)" $imported = Import-Module -Name $Name -RequiredVersion $resolved.Version -Force -Global -PassThru -ErrorAction Stop From e83c53692cd4a223c426f838d26b6df6a20d93ed Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 12 Jul 2026 12:20:49 +0200 Subject: [PATCH 3/3] Address review: use Prerelease string for filtering and fail fast before unloading Filter/sort on the PSResourceGet 'Prerelease' string (present on both Install-PSResource and Get-InstalledPSResource output) instead of IsPrerelease; and move the unresolvable-constraint throw before Get-Module|Remove-Module so session state is not mutated when the function is going to fail. --- src/Invoke-Pester.Helpers.psm1 | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Invoke-Pester.Helpers.psm1 b/src/Invoke-Pester.Helpers.psm1 index 75bd95b7..a7829f8a 100644 --- a/src/Invoke-Pester.Helpers.psm1 +++ b/src/Invoke-Pester.Helpers.psm1 @@ -1365,9 +1365,11 @@ function Install-PSResourceWithRetry { # already present, Install-PSResource returns nothing, so fall back to the newest installed # version that satisfies the requested constraint. When prerelease versions are not requested, # never resolve to one; when a stable and a prerelease share a base version, the stable one wins. + # PSResourceGet exposes prerelease state as the 'Prerelease' string (empty for stable), present on + # both Install-PSResource and Get-InstalledPSResource output. $sortOrder = @( @{ Expression = 'Version'; Descending = $true } - @{ Expression = 'IsPrerelease'; Descending = $false } + @{ Expression = { -not [string]::IsNullOrWhiteSpace($_.Prerelease) }; Descending = $false } ) $candidates = @($installed | Where-Object { $_.Name -eq $Name }) if ($candidates.Count -eq 0) { @@ -1378,10 +1380,18 @@ function Install-PSResourceWithRetry { $candidates = @(Get-InstalledPSResource @getParams) } if (-not $Prerelease) { - $candidates = @($candidates | Where-Object { -not $_.IsPrerelease }) + $candidates = @($candidates | Where-Object { [string]::IsNullOrWhiteSpace($_.Prerelease) }) } $resolved = $candidates | Sort-Object -Property $sortOrder | Select-Object -First 1 + # A version constraint was requested but no satisfying installed version could be resolved. Fail fast + # before mutating session state; importing unconstrained would silently load an arbitrary copy from + # PSModulePath (for example the runner's preinstalled module), defeating the deterministic, + # constraint-driven selection this function exists to guarantee. + if (-not $resolved -and -not [string]::IsNullOrWhiteSpace($Version)) { + throw "No installed '$Name' version satisfies constraint '$Version'; refusing to import an unconstrained version." + } + # Remove every already-loaded instance (all versions, including nested) from the session so the # chosen version is the only one loaded, then import into the global session state so the resolved # version is the one every subsequent command (for example Invoke-Pester in exec.ps1) uses, instead @@ -1390,12 +1400,6 @@ function Install-PSResourceWithRetry { if ($resolved) { Write-Output "Importing module: $Name $($resolved.Version)" $imported = Import-Module -Name $Name -RequiredVersion $resolved.Version -Force -Global -PassThru -ErrorAction Stop - } elseif (-not [string]::IsNullOrWhiteSpace($Version)) { - # A version constraint was requested but no satisfying installed version could be resolved. Importing the - # module unconstrained here would silently load an arbitrary copy from PSModulePath (for example the - # runner's preinstalled module), defeating the deterministic, constraint-driven selection this function - # exists to guarantee. Fail fast instead. - throw "No installed '$Name' version satisfies constraint '$Version'; refusing to import an unconstrained version." } else { $imported = Import-Module -Name $Name -Force -Global -PassThru -ErrorAction Stop }