From 3eee3c81ea63f166615888779a88c257c8d32a2b Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 4 Sep 2026 16:25:05 -0400 Subject: [PATCH 1/4] ci: Install the built module from a local repository CI has built this module on every push and never installed it, so nothing observes an install-time break: a RequiredModules entry naming a version that does not exist, or a dependency dropped from the manifest that is still needed. Several breaks this cycle are exactly that shape. Add a repository-local workflow that publishes the built module into a temporary file-share repository alongside mirrored copies of its required modules, then saves and imports it in a process whose PSModulePath holds only an empty directory and a tools directory. It runs twice on windows-latest, once with a PowerShell 7 probe and once with a Windows PowerShell 5.1 probe. The test logic lives in tests/InstallTime/ as PowerShell rather than inline YAML, so it can be run locally and reviewed as code. A negative control republishes the module with an unsatisfiable dependency and requires the save to fail with nothing on disk, so a green run is evidence rather than an assumption. Refs psake/PowerShellBuild#229 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WTjuRainebRsnmcg8fRCKB --- .github/workflows/install-test.yml | 44 ++ cspell.json | 3 + .../repository-specific.instructions.md | 17 + .../Invoke-LocalRepositoryInstallTest.ps1 | 521 ++++++++++++++++++ .../Test-InstallTimeDependency.ps1 | 277 ++++++++++ 5 files changed, 862 insertions(+) create mode 100644 .github/workflows/install-test.yml create mode 100644 tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 create mode 100644 tests/InstallTime/Test-InstallTimeDependency.ps1 diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml new file mode 100644 index 0000000..92c9a53 --- /dev/null +++ b/.github/workflows/install-test.yml @@ -0,0 +1,44 @@ +name: Install Test +permissions: + contents: read +on: + push: + branches: [ main ] + pull_request: + workflow_dispatch: +jobs: + # CI builds this module on every push but has never installed it, and a build cannot observe an + # install-time break - a RequiredModules entry naming a version that does not exist, or a + # dependency that was dropped from the manifest but is still needed. This workflow publishes the + # built module into a temporary file-share repository alongside mirrored copies of its required + # modules and then, in a process whose PSModulePath holds only an empty directory, saves and + # imports it. See psake/PowerShellBuild#229. + # + # It runs alongside the shared ModuleCI.yml call in test.yml rather than inside it, because the + # shared workflow is used by the whole psake organization and this check is specific to this + # repository for now. + # + # Both jobs run on windows-latest so that the same runner can host a real Windows PowerShell 5.1 + # engine as well as PowerShell 7. What the test does and does not prove is documented at the top + # of tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 - read that before treating a green + # run here as broader coverage than it is. + local-repository-install: + name: Local repository install (${{ matrix.editionName }}) + runs-on: windows-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - editionName: PowerShell 7 + probeEdition: PowerShell7 + - editionName: Windows PowerShell 5.1 + probeEdition: WindowsPowerShell + steps: + - uses: actions/checkout@v7 + + # The build and the publish are the same work for either edition, so this step always runs + # on PowerShell 7; only the isolated probe process runs on the edition under test. + - name: Install from a local repository + shell: pwsh + run: ./tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 -ProbeEdition ${{ matrix.probeEdition }} diff --git a/cspell.json b/cspell.json index d136d00..0ad94bb 100644 --- a/cspell.json +++ b/cspell.json @@ -11,6 +11,9 @@ ], "words": [ "Authenticode", + "coreclr", + "fullclr", + "LASTEXITCODE", "psake", "MAML" ], diff --git a/instructions/repository-specific.instructions.md b/instructions/repository-specific.instructions.md index 7ca6e04..8ce01df 100644 --- a/instructions/repository-specific.instructions.md +++ b/instructions/repository-specific.instructions.md @@ -42,6 +42,7 @@ PowerShellBuild/ │ ├── psakeFile.ps1 # Tasks consumers import │ └── IB.tasks.ps1 # Invoke-Build entry (aliased as PowerShellBuild.IB.Tasks) ├── tests/ # Pester 6+ tests +│ ├── InstallTime/ # Local-repository install test (CI only; not Pester) │ └── TestModule/ # Sample module exercised by the test suite ├── build.ps1 # Main build entry point for THIS repo ├── build.settings.ps1 # Build settings for THIS repo's own psake build @@ -288,6 +289,22 @@ Supporting files: `tests/MetaFixers.psm1` (helpers for `Meta.tests.ps1`) and `te `./build.ps1 -Task Test -Bootstrap` across a `ubuntu-latest`, `windows-latest`, `macOS-latest` matrix on PowerShell 7 and again on Windows PowerShell 5.1, and publishes the test results +### Install test workflow (`.github/workflows/install-test.yml`) + +- Triggers: push to `main`, pull requests, manual dispatch +- Runs on: `windows-latest`, once with a PowerShell 7 probe and once with a Windows + PowerShell 5.1 probe +- Repository-local rather than part of the shared workflow, because the coverage is specific to + this repository for now (psake/PowerShellBuild#229) +- Runs `tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1`, which publishes the built + module into a temporary file-share repository alongside mirrored copies of its + `RequiredModules`, then saves and imports it in a process whose `PSModulePath` holds only an + empty directory. A build cannot observe an install-time break; this can +- Includes a negative control that republishes the module with an unsatisfiable dependency and + requires the save to fail, so a green run is evidence rather than an assumption +- The comment-based help at the top of that script records what the test deliberately does not + cover — read it before treating a green run as broader coverage than it is + ### Publish workflow (`.github/workflows/publish.yaml`) - Triggers: manual dispatch, GitHub release published diff --git a/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 new file mode 100644 index 0000000..85a8280 --- /dev/null +++ b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 @@ -0,0 +1,521 @@ +#requires -Version 7.0 +<# +.SYNOPSIS + Publishes the built PowerShellBuild module into a temporary file-share repository and proves, + in an isolated process, that its declared dependencies resolve at install time. + +.DESCRIPTION + CI builds this module on every push but has never installed it. A build cannot observe an + install-time break: a RequiredModules entry naming a version that does not exist, or a + dependency that was removed from the manifest but is still needed. This script closes that + gap without touching the PowerShell Gallery or the machine's module store. See + psake/PowerShellBuild#229 for the measurements behind it. + + What it does: + + 1. Builds the module (unless -SkipBuild). + 2. Stages a tools module path holding only PowerShellGet and PackageManagement. + 3. Registers a temporary file-share repository under the working root. + 4. Mirrors every RequiredModules entry from the PowerShell Gallery into that repository, + because Save-Module resolves dependencies only from the repository it saves from. + 5. Publishes the built module into that repository. + 6. Runs Test-InstallTimeDependency.ps1 in a fresh process of the requested edition, whose + PSModulePath holds only an empty directory and the tools path. + 7. Negative control (unless -SkipNegativeControl): republishes a scratch copy of the module + whose manifest demands a dependency version that does not exist, and asserts the save + fails with nothing on disk. + 8. Unregisters the temporary repository however the run ends. + + What this test deliberately does NOT cover. A local repository is a snapshot of three pinned + packages, so a green run says nothing about: + + - the live dependency graph - the real gallery resolves the newest package satisfying each + range, not the floor version mirrored here; + - transitive dependency drift in those packages' own dependencies; + - PowerShell Gallery ingestion of the published package; + - real network transport, especially the TLS and proxy behavior of Windows PowerShell 5.1. + + Do not extend this test to prerelease behavior. Against a file-share repository, + PowerShellGet 2.x gets prerelease gating wrong - Find-Module without -AllowPrerelease returns + a prerelease package - so any prerelease assertion made here would be testing the harness + rather than the module. PSResourceGet gates the same folder correctly if that coverage is + ever wanted. + +.PARAMETER ProbeEdition + Which PowerShell edition runs the isolated probe. This script itself always runs on + PowerShell 7, because the build and the publish are the same work for both editions; only the + install is edition-specific. + +.PARAMETER WorkingRootPath + Where the local repository, the tools module path, and the isolated module paths are created. + Defaults to a directory under the runner temporary path, or the system temporary path when + running outside CI. + +.PARAMETER RepositoryName + The name to register the temporary file-share repository under. + +.PARAMETER ModuleName + The module under test. + +.PARAMETER SkipBuild + Reuse the existing contents of the Output directory instead of building. + +.PARAMETER SkipNegativeControl + Skip the negative control. The positive result alone is not evidence that the test can fail, + so only skip this while iterating locally. + +.EXAMPLE + ./tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 -ProbeEdition PowerShell7 + +.EXAMPLE + ./tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 -ProbeEdition WindowsPowerShell -SkipBuild +#> +[CmdletBinding()] +param( + [ValidateSet('PowerShell7', 'WindowsPowerShell')] + [string] + $ProbeEdition = 'PowerShell7', + + [ValidateNotNullOrEmpty()] + [string] + $WorkingRootPath, + + [ValidateNotNullOrEmpty()] + [string] + $RepositoryName = 'PowerShellBuildInstallTest', + + [ValidateNotNullOrEmpty()] + [string] + $ModuleName = 'PowerShellBuild', + + [switch] + $SkipBuild, + + [switch] + $SkipNegativeControl +) + +$ErrorActionPreference = 'Stop' + +# The versions of the two packaging modules staged into the isolated probe's tools path. They are +# taken from the PowerShell Gallery rather than copied off the running machine because the +# PackageManagement that ships inside PowerShell 7 carries coreclr binaries only and will not load +# on Windows PowerShell 5.1, while the gallery package carries both coreclr and fullclr. +$powerShellGetVersion = '2.2.5' +$packageManagementVersion = '1.4.8.1' + +# The dependency version demanded by the negative control. Nothing resolvable may ever exist at +# this version. +$unsatisfiableDependencyVersion = '99.0.0' + +# The module version the negative control publishes under, so that it can never be confused with +# the real package in the same repository. +$negativeControlModuleVersion = '999.0.0' + +function Reset-Directory { + <# + .SYNOPSIS + Removes a directory if it exists and recreates it empty. + + .PARAMETER Path + The directory to recreate. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Path + ) + + if ($PSCmdlet.ShouldProcess($Path, 'Recreate directory')) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $Path | Out-Null + } +} + +function Get-ProbeExecutablePath { + <# + .SYNOPSIS + Resolves the executable that runs the isolated probe for a PowerShell edition. + + .PARAMETER Edition + Either PowerShell7 or WindowsPowerShell. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [ValidateSet('PowerShell7', 'WindowsPowerShell')] + [string] + $Edition + ) + + if ($Edition -eq 'WindowsPowerShell') { + $windowsPowerShellPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\WindowsPowerShell\v1.0\powershell.exe' + if (-not (Test-Path $windowsPowerShellPath)) { + throw "Windows PowerShell was not found at $windowsPowerShellPath. The WindowsPowerShell probe edition requires a Windows runner." + } + return $windowsPowerShellPath + } + + $powerShell7Command = Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $powerShell7Command) { + throw 'pwsh was not found on PATH.' + } + return $powerShell7Command.Source +} + +function Publish-MirroredDependency { + <# + .SYNOPSIS + Copies one dependency from the PowerShell Gallery into the temporary local repository. + + .DESCRIPTION + Save-Module and Install-Module resolve a package's dependencies only from the repository the + package itself came from, so every RequiredModules entry has to exist in the local repository + before the module under test can be saved from it. + + .PARAMETER Name + The dependency module name. + + .PARAMETER Version + The exact dependency version to mirror. + + .PARAMETER StagingPath + A directory the gallery copy is saved into before being published. + + .PARAMETER RepositoryName + The local repository to publish the mirrored copy into. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Name, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Version, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $StagingPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RepositoryName + ) + + Save-Module -Name $Name -RequiredVersion $Version -Repository 'PSGallery' -Path $StagingPath -Force + $savedVersionPath = Join-Path -Path $StagingPath -ChildPath "$Name\$Version" + if (-not (Test-Path $savedVersionPath)) { + throw "Saving $Name $Version from the PowerShell Gallery did not produce $savedVersionPath." + } + Publish-Module -Path $savedVersionPath -Repository $RepositoryName -NuGetApiKey 'local-repository-not-a-real-key' -Force + Write-Host " mirrored $Name $Version" +} + +function Invoke-IsolatedProbe { + <# + .SYNOPSIS + Runs the isolated install probe in a fresh process, streaming its output. + +.DESCRIPTION + The probe's own output is written straight through, so the caller reads the result from + $LASTEXITCODE after this function returns rather than from a return value. + + .PARAMETER ExecutablePath + The PowerShell executable to run the probe with. + + .PARAMETER ProbeScriptPath + The path to Test-InstallTimeDependency.ps1. + + .PARAMETER IsolatedModulePath + An empty directory used as the Save-Module target. + + .PARAMETER ToolsModulePath + The directory holding only PowerShellGet and PackageManagement. + + .PARAMETER RepositoryName + The local repository to save from. + + .PARAMETER ModuleName + The module under test. + + .PARAMETER RequiredVersion + The exact version of the module under test to save. + + .PARAMETER ExpectSaveToFail + Run the probe in negative-control mode. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ExecutablePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ProbeScriptPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $IsolatedModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ToolsModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RepositoryName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ModuleName, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RequiredVersion, + + [switch] + $ExpectSaveToFail + ) + + Reset-Directory -Path $IsolatedModulePath + + $probeArguments = @( + '-NoProfile' + '-ExecutionPolicy', 'Bypass' + '-File', $ProbeScriptPath + '-IsolatedModulePath', $IsolatedModulePath + '-ToolsModulePath', $ToolsModulePath + '-RepositoryName', $RepositoryName + '-ModuleName', $ModuleName + '-RequiredVersion', $RequiredVersion + ) + if ($ExpectSaveToFail) { + $probeArguments += '-ExpectSaveToFail' + } + + & $ExecutablePath @probeArguments +} + +$repositoryRootPath = (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..\..')).Path +$probeScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'Test-InstallTimeDependency.ps1' + +if (-not $WorkingRootPath) { + $temporaryRootPath = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $WorkingRootPath = Join-Path -Path $temporaryRootPath -ChildPath 'PowerShellBuildInstallTest' +} + +$paths = @{ + LocalRepository = Join-Path -Path $WorkingRootPath -ChildPath 'localRepository' + DependencyStaging = Join-Path -Path $WorkingRootPath -ChildPath 'dependencyStaging' + Tools = Join-Path -Path $WorkingRootPath -ChildPath 'toolsModulePath' + Isolated = Join-Path -Path $WorkingRootPath -ChildPath 'isolatedModulePath' + NegativeIsolated = Join-Path -Path $WorkingRootPath -ChildPath 'negativeIsolatedModulePath' + NegativeModule = Join-Path -Path $WorkingRootPath -ChildPath 'negativeModule' + NegativeStubDependency = Join-Path -Path $WorkingRootPath -ChildPath 'negativeStubDependency' +} + +Write-Host '=== Local repository install test ===' +Write-Host "Repository root: $repositoryRootPath" +Write-Host "Working root: $WorkingRootPath" +Write-Host "Probe edition: $ProbeEdition" + +$probeExecutablePath = Get-ProbeExecutablePath -Edition $ProbeEdition +Write-Host "Probe executable: $probeExecutablePath" + +Write-Host '' +Write-Host '=== 1. Build the module ===' +if ($SkipBuild) { + Write-Host 'Skipped; reusing the existing Output directory.' +} else { + # Built in a child process so that psake, BuildHelpers, and the rest of the build's own + # dependencies never enter this session. + Push-Location -Path $repositoryRootPath + try { + & (Get-ProbeExecutablePath -Edition 'PowerShell7') -NoProfile -File (Join-Path -Path $repositoryRootPath -ChildPath 'build.ps1') -Task Build -Bootstrap + if ($LASTEXITCODE -ne 0) { + throw "The build failed with exit code $LASTEXITCODE." + } + } finally { + Pop-Location + } +} + +$builtModulePath = Get-ChildItem -Path (Join-Path -Path $repositoryRootPath -ChildPath "Output\$ModuleName") -Directory | + Sort-Object -Property { [version]$_.Name } -Descending | + Select-Object -First 1 +if (-not $builtModulePath) { + throw "No built module was found under $repositoryRootPath\Output\$ModuleName." +} +$builtModuleVersion = $builtModulePath.Name +$builtManifestPath = Join-Path -Path $builtModulePath.FullName -ChildPath "$ModuleName.psd1" +Write-Host "Built $ModuleName $builtModuleVersion at $($builtModulePath.FullName)" + +$builtManifest = Import-PowerShellDataFile -Path $builtManifestPath +$requiredModules = @($builtManifest.RequiredModules) +Write-Host 'RequiredModules declared by the built manifest:' +foreach ($requiredModule in $requiredModules) { + Write-Host " $($requiredModule.ModuleName) $($requiredModule.ModuleVersion)" +} +if ($requiredModules.Count -eq 0) { + throw 'The built manifest declares no RequiredModules; there is nothing for this test to prove.' +} + +Write-Host '' +Write-Host '=== 2. Stage the tools module path ===' +Reset-Directory -Path $paths.Tools +PackageManagement\Get-PackageProvider -Name 'NuGet' -ForceBootstrap | Out-Null +Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted +Save-Module -Name 'PowerShellGet' -RequiredVersion $powerShellGetVersion -Repository 'PSGallery' -Path $paths.Tools -Force +Save-Module -Name 'PackageManagement' -RequiredVersion $packageManagementVersion -Repository 'PSGallery' -Path $paths.Tools -Force +# Saving PowerShellGet brings its own PackageManagement dependency along, which may be a different +# version. Keep only the pinned one so the probe has exactly one candidate to import. +Get-ChildItem -Path (Join-Path -Path $paths.Tools -ChildPath 'PackageManagement') -Directory | + Where-Object { $_.Name -ne $packageManagementVersion } | + Remove-Item -Recurse -Force +Get-ChildItem -Path $paths.Tools -Directory | ForEach-Object { + Write-Host " staged $($_.Name) $((Get-ChildItem -Path $_.FullName -Directory).Name -join ', ')" +} + +try { + Write-Host '' + Write-Host '=== 3. Register the temporary file-share repository ===' + Reset-Directory -Path $paths.LocalRepository + if (Get-PSRepository -Name $RepositoryName -ErrorAction SilentlyContinue) { + Unregister-PSRepository -Name $RepositoryName + } + Register-PSRepository -Name $RepositoryName -SourceLocation $paths.LocalRepository -PublishLocation $paths.LocalRepository -InstallationPolicy Trusted + Write-Host "Registered $RepositoryName at $($paths.LocalRepository)" + + Write-Host '' + Write-Host '=== 4. Mirror the required modules into the local repository ===' + Reset-Directory -Path $paths.DependencyStaging + foreach ($requiredModule in $requiredModules) { + Publish-MirroredDependency -Name $requiredModule.ModuleName -Version $requiredModule.ModuleVersion -StagingPath $paths.DependencyStaging -RepositoryName $RepositoryName + } + + Write-Host '' + Write-Host "=== 5. Publish $ModuleName $builtModuleVersion into the local repository ===" + # Publish-Module validates every RequiredModules entry against the destination repository and + # refuses to publish when one cannot be resolved. The same gate runs against the PowerShell + # Gallery, so an unsatisfiable dependency fails the release rather than reaching consumers. + Publish-Module -Path $builtModulePath.FullName -Repository $RepositoryName -NuGetApiKey 'local-repository-not-a-real-key' -Force + Get-ChildItem -Path $paths.LocalRepository | ForEach-Object { Write-Host " $($_.Name)" } + + Write-Host '' + Write-Host "=== 6. Positive probe on $ProbeEdition ===" + Invoke-IsolatedProbe -ExecutablePath $probeExecutablePath -ProbeScriptPath $probeScriptPath ` + -IsolatedModulePath $paths.Isolated -ToolsModulePath $paths.Tools -RepositoryName $RepositoryName ` + -ModuleName $ModuleName -RequiredVersion $builtModuleVersion + $positiveExitCode = $LASTEXITCODE + if ($positiveExitCode -ne 0) { + throw "The install probe failed on $ProbeEdition with exit code $positiveExitCode." + } + + if ($SkipNegativeControl) { + Write-Host '' + Write-Host '=== 7. Negative control skipped ===' + } else { + Write-Host '' + Write-Host '=== 7. Negative control: an unsatisfiable dependency must fail the save ===' + + # Break a scratch copy of the built module rather than the build output itself. + Reset-Directory -Path $paths.NegativeModule + $negativeModulePath = Join-Path -Path $paths.NegativeModule -ChildPath $ModuleName + Copy-Item -Path $builtModulePath.FullName -Destination $negativeModulePath -Recurse -Force + $negativeManifestPath = Join-Path -Path $negativeModulePath -ChildPath "$ModuleName.psd1" + + $brokenDependencyName = $requiredModules[0].ModuleName + $brokenDependencyVersion = $requiredModules[0].ModuleVersion + $originalManifestContent = Get-Content -Path $negativeManifestPath -Raw + $negativeManifestContent = $originalManifestContent ` + -replace "(?m)^(?\s*)ModuleVersion(?\s*)=\s*'[^']+'", "`${indent}ModuleVersion`${spacing}= '$negativeControlModuleVersion'" ` + -replace "(ModuleName\s*=\s*'$([regex]::Escape($brokenDependencyName))'\s*;\s*ModuleVersion\s*=\s*)'$([regex]::Escape($brokenDependencyVersion))'", "`${1}'$unsatisfiableDependencyVersion'" + Set-Content -Path $negativeManifestPath -Value $negativeManifestContent -NoNewline -Encoding utf8BOM + + # A silent regex miss here would turn the negative control into a second positive run, so + # confirm both edits actually landed before relying on them. + $negativeManifest = Import-PowerShellDataFile -Path $negativeManifestPath + if ($negativeManifest.ModuleVersion -ne $negativeControlModuleVersion) { + throw "The negative control could not rewrite ModuleVersion to $negativeControlModuleVersion." + } + $brokenRequirement = @($negativeManifest.RequiredModules) | Where-Object { $_.ModuleName -eq $brokenDependencyName } + if ($brokenRequirement.ModuleVersion -ne $unsatisfiableDependencyVersion) { + throw "The negative control could not rewrite the $brokenDependencyName requirement to $unsatisfiableDependencyVersion." + } + Write-Host "Broken manifest: $ModuleName $negativeControlModuleVersion requiring $brokenDependencyName $unsatisfiableDependencyVersion" + + Write-Host '' + Write-Host '--- 7a. Publish-Module must refuse the broken package ---' + $publishRefused = $false + try { + Publish-Module -Path $negativeModulePath -Repository $RepositoryName -NuGetApiKey 'local-repository-not-a-real-key' -Force + } catch { + $publishRefused = $true + Write-Host "Publish-Module refused it: $($_.FullyQualifiedErrorId)" + } + if (-not $publishRefused) { + throw "Publish-Module accepted a package requiring $brokenDependencyName $unsatisfiableDependencyVersion; the negative control cannot be staged." + } + + Write-Host '' + Write-Host '--- 7b. Stage the broken package behind a throwaway stub ---' + # Publish-Module will not stage the broken package while the dependency is missing, so + # publish a stub at the unsatisfiable version, publish the broken package, then delete the + # stub package again. What is left is a package whose dependency cannot be resolved - + # exactly the shape of a manifest that names a version that was never released. + $stubVersionPath = Join-Path -Path $paths.NegativeStubDependency -ChildPath "$brokenDependencyName\$unsatisfiableDependencyVersion" + Reset-Directory -Path $stubVersionPath + Set-Content -Path (Join-Path -Path $stubVersionPath -ChildPath "$brokenDependencyName.psm1") -Value 'function Invoke-InstallTestStub { }' -Encoding utf8BOM + New-ModuleManifest -Path (Join-Path -Path $stubVersionPath -ChildPath "$brokenDependencyName.psd1") ` + -RootModule "$brokenDependencyName.psm1" -ModuleVersion $unsatisfiableDependencyVersion ` + -Author 'PowerShellBuild install test' -CompanyName 'PowerShellBuild install test' ` + -Description 'Throwaway stub used only to stage the negative control package.' ` + -FunctionsToExport 'Invoke-InstallTestStub' + Publish-Module -Path $stubVersionPath -Repository $RepositoryName -NuGetApiKey 'local-repository-not-a-real-key' -Force + Publish-Module -Path $negativeModulePath -Repository $RepositoryName -NuGetApiKey 'local-repository-not-a-real-key' -Force + $stubPackagePath = Join-Path -Path $paths.LocalRepository -ChildPath "$brokenDependencyName.$unsatisfiableDependencyVersion.nupkg" + Remove-Item -Path $stubPackagePath -Force + Write-Host "Removed the stub package $brokenDependencyName $unsatisfiableDependencyVersion from the local repository." + + Write-Host '' + Write-Host "--- 7c. Save-Module must now fail on $ProbeEdition ---" + Invoke-IsolatedProbe -ExecutablePath $probeExecutablePath -ProbeScriptPath $probeScriptPath ` + -IsolatedModulePath $paths.NegativeIsolated -ToolsModulePath $paths.Tools -RepositoryName $RepositoryName ` + -ModuleName $ModuleName -RequiredVersion $negativeControlModuleVersion -ExpectSaveToFail + $negativeExitCode = $LASTEXITCODE + if ($negativeExitCode -ne 0) { + throw "The negative control failed on $ProbeEdition with exit code $negativeExitCode; this test cannot be trusted to fail when the manifest is broken." + } + + Remove-Item -Path (Join-Path -Path $paths.LocalRepository -ChildPath "$ModuleName.$negativeControlModuleVersion.nupkg") -Force -ErrorAction SilentlyContinue + } + + Write-Host '' + Write-Host "=== Local repository install test passed on $ProbeEdition ===" +} finally { + if (Get-PSRepository -Name $RepositoryName -ErrorAction SilentlyContinue) { + Unregister-PSRepository -Name $RepositoryName + Write-Host "Unregistered $RepositoryName." + } +} diff --git a/tests/InstallTime/Test-InstallTimeDependency.ps1 b/tests/InstallTime/Test-InstallTimeDependency.ps1 new file mode 100644 index 0000000..929e1cd --- /dev/null +++ b/tests/InstallTime/Test-InstallTimeDependency.ps1 @@ -0,0 +1,277 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Saves PowerShellBuild from a local repository inside an isolated process and asserts that + its declared dependencies resolved, loaded, and that the module exports its public surface. + +.DESCRIPTION + This script is the isolated half of the local-repository install test. It is never run in + the same process as Invoke-LocalRepositoryInstallTest.ps1 - that script starts it in a fresh + PowerShell 7 or Windows PowerShell 5.1 process so that the PSModulePath below is the only + module path the process has ever seen. + + The isolated PSModulePath contains exactly two directories: + + 1. IsolatedModulePath - empty when the process starts; the Save-Module target. + 2. ToolsModulePath - PowerShellGet and PackageManagement only. + + Nothing installed on the machine can satisfy a dependency from that path, which is the whole + point: on a developer machine and on a CI runner that has just run `./build.ps1 -Bootstrap`, + every dependency is already installed, and without this isolation the test would pass no + matter what the manifest declared. + + Both modules in the tools path are imported by explicit manifest path rather than by name. + Importing PowerShellGet by name on Windows PowerShell 5.1 asks the module auto-loader to + resolve helper modules from a PSModulePath that no longer contains the engine's own module + directory, and that fails. + +.PARAMETER IsolatedModulePath + An existing empty directory. It becomes the first PSModulePath entry and the Save-Module + target. + +.PARAMETER ToolsModulePath + A directory holding only PowerShellGet and PackageManagement. + +.PARAMETER RepositoryName + The name of the registered local file-share repository to save from. + +.PARAMETER ModuleName + The module under test. + +.PARAMETER RequiredVersion + The exact version of the module under test to save. Passing it explicitly keeps this run + independent of any other package that happens to sit in the local repository. + +.PARAMETER ExpectSaveToFail + Negative-control mode. Asserts that Save-Module fails and that nothing at all landed in the + isolated path. Used to prove this test is capable of failing. + +.PARAMETER ExpectedAlias + The alias the module under test is expected to export. + +.EXAMPLE + ./Test-InstallTimeDependency.ps1 -IsolatedModulePath C:\scratch\empty -ToolsModulePath C:\scratch\tools -RepositoryName LocalTest -RequiredVersion 0.8.2 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $IsolatedModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ToolsModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RepositoryName, + + [ValidateNotNullOrEmpty()] + [string] + $ModuleName = 'PowerShellBuild', + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $RequiredVersion, + + [switch] + $ExpectSaveToFail, + + [ValidateNotNullOrEmpty()] + [string] + $ExpectedAlias = 'PowerShellBuild.IB.Tasks' +) + +$ErrorActionPreference = 'Stop' + +function Confirm-Condition { + <# + .SYNOPSIS + Throws when a test condition is not met, and reports it when it is. + + .PARAMETER Condition + The result of the check being asserted. + + .PARAMETER Description + A description of what the check proves, used in both the pass and the failure message. + #> + [CmdletBinding()] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [bool] + $Condition, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $Description + ) + + if (-not $Condition) { + throw "ASSERTION FAILED: $Description" + } + Write-Host " PASS: $Description" +} + +function Set-IsolatedModulePath { + <# + .SYNOPSIS + Points PSModulePath at the isolated save target and the tools directory, and nothing else. + + .PARAMETER IsolatedModulePath + The empty directory that is the Save-Module target. + + .PARAMETER ToolsModulePath + The directory holding only PowerShellGet and PackageManagement. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $IsolatedModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ToolsModulePath + ) + + if ($PSCmdlet.ShouldProcess('PSModulePath', 'Restrict to the isolated module paths')) { + $env:PSModulePath = ($IsolatedModulePath, $ToolsModulePath) -join [System.IO.Path]::PathSeparator + } +} + +try { + Set-IsolatedModulePath -IsolatedModulePath $IsolatedModulePath -ToolsModulePath $ToolsModulePath + + Write-Host "=== Isolated probe: $($PSVersionTable.PSEdition) $($PSVersionTable.PSVersion) ===" + Write-Host 'PSModulePath:' + $env:PSModulePath -split [System.IO.Path]::PathSeparator | ForEach-Object { Write-Host " $_" } + + $packageManagementManifestPath = Get-ChildItem -Path (Join-Path $ToolsModulePath 'PackageManagement') -Recurse -Filter 'PackageManagement.psd1' | + Select-Object -First 1 + $powerShellGetManifestPath = Get-ChildItem -Path (Join-Path $ToolsModulePath 'PowerShellGet') -Recurse -Filter 'PowerShellGet.psd1' | + Select-Object -First 1 + Import-Module -Name $packageManagementManifestPath.FullName -Force + Import-Module -Name $powerShellGetManifestPath.FullName -Force + Write-Host "PackageManagement in use: $((Get-Module -Name PackageManagement).Version)" + Write-Host "PowerShellGet in use: $((Get-Module -Name PowerShellGet).Version)" + + # Read the dependency contract from the package that is about to be saved rather than from a + # hardcoded list, so this test keeps testing the manifest instead of a stale copy of it. + $declaredDependencies = (Find-Module -Name $ModuleName -RequiredVersion $RequiredVersion -Repository $RepositoryName).Dependencies + $dependencyNames = @($declaredDependencies | ForEach-Object { $_.Name }) + Write-Host "Declared dependencies of $ModuleName $RequiredVersion : $($dependencyNames -join ', ')" + + # PowerShellGet restores the machine's default PSModulePath entries when a command such as + # Find-Module or Save-Module returns. Every check below depends on the isolated path, so it is + # re-asserted after each PowerShellGet call rather than trusted to survive one. + Set-IsolatedModulePath -IsolatedModulePath $IsolatedModulePath -ToolsModulePath $ToolsModulePath + + Write-Host '' + Write-Host '--- Isolation: nothing under test may be visible before the save ---' + foreach ($name in @($ModuleName) + $dependencyNames) { + $visibleModules = Get-Module -Name $name -ListAvailable -ErrorAction SilentlyContinue + Confirm-Condition -Condition ($null -eq $visibleModules) -Description "$name is not visible on the isolated PSModulePath" + } + + Write-Host '' + Write-Host "--- Save-Module $ModuleName $RequiredVersion -Repository $RepositoryName ---" + $saveFailed = $false + try { + Save-Module -Name $ModuleName -RequiredVersion $RequiredVersion -Repository $RepositoryName -Path $IsolatedModulePath -Force -ErrorAction Stop + Write-Host 'Save-Module succeeded.' + } catch { + $saveFailed = $true + Write-Host 'Save-Module failed.' + Write-Host " FullyQualifiedErrorId: $($_.FullyQualifiedErrorId)" + Write-Host " Message: $($_.Exception.Message)" + } + + $savedModuleDirectories = @(Get-ChildItem -Path $IsolatedModulePath -Directory -ErrorAction SilentlyContinue) + Write-Host 'What landed in the isolated path:' + if ($savedModuleDirectories.Count -eq 0) { + Write-Host ' (nothing)' + } + foreach ($savedModuleDirectory in $savedModuleDirectories) { + $savedVersions = @(Get-ChildItem -Path $savedModuleDirectory.FullName -Directory -ErrorAction SilentlyContinue).Name -join ', ' + Write-Host (" {0,-16} {1}" -f $savedModuleDirectory.Name, $savedVersions) + } + + Write-Host '' + if ($ExpectSaveToFail) { + Write-Host '--- Negative control assertions ---' + Confirm-Condition -Condition $saveFailed -Description 'Save-Module refused a package whose dependency version does not exist' + Confirm-Condition -Condition ($savedModuleDirectories.Count -eq 0) -Description 'Nothing was left in the isolated path by the failed save' + Write-Host '' + Write-Host 'RESULT: negative control passed - this test can fail.' + exit 0 + } + + Write-Host '--- Positive assertions ---' + Confirm-Condition -Condition (-not $saveFailed) -Description "Save-Module resolved $ModuleName $RequiredVersion from the local repository" + + # Save-Module restored the machine's default PSModulePath entries on the way out. Left alone, + # the import below would silently fall through to an installed copy of the module or of one of + # its dependencies, and the test would prove nothing. + Set-IsolatedModulePath -IsolatedModulePath $IsolatedModulePath -ToolsModulePath $ToolsModulePath + + foreach ($declaredDependency in $declaredDependencies) { + $dependencyName = $declaredDependency.Name + # PowerShellGet reports the floor of the dependency range as MinimumVersion, and the local + # repository holds exactly that version, so an exact match is the right assertion here. + $expectedVersion = $declaredDependency.MinimumVersion + $savedDependency = Get-Module -Name $dependencyName -ListAvailable -ErrorAction SilentlyContinue | + Where-Object { $_.ModuleBase -like "$IsolatedModulePath*" } + Confirm-Condition -Condition ($null -ne $savedDependency) -Description "$dependencyName was saved into the isolated path" + Confirm-Condition -Condition ([bool]($savedDependency.Version -contains [version]$expectedVersion)) -Description "$dependencyName resolved at the declared version $expectedVersion" + } + + Write-Host '' + Write-Host "--- Import $ModuleName by explicit manifest path ---" + $savedManifestPath = Get-ChildItem -Path (Join-Path $IsolatedModulePath $ModuleName) -Recurse -Filter "$ModuleName.psd1" | + Select-Object -First 1 + Import-Module -Name $savedManifestPath.FullName -Force -ErrorAction Stop + $importedModule = Get-Module -Name $ModuleName + Write-Host "Imported $($importedModule.Name) $($importedModule.Version) from $($importedModule.ModuleBase)" + Confirm-Condition -Condition ($importedModule.ModuleBase -like "$IsolatedModulePath*") -Description "$ModuleName was imported from the isolated path" + + Write-Host '' + Write-Host '--- Required modules loaded into the session ---' + foreach ($dependencyName in $dependencyNames) { + $loadedDependency = Get-Module -Name $dependencyName + Confirm-Condition -Condition ($null -ne $loadedDependency) -Description "$dependencyName was loaded by the import" + Confirm-Condition -Condition ($loadedDependency.ModuleBase -like "$IsolatedModulePath*") -Description "$dependencyName was loaded from the isolated path, not from an installed copy" + Write-Host " $dependencyName $($loadedDependency.Version) from $($loadedDependency.ModuleBase)" + } + + Write-Host '' + Write-Host '--- Exported command surface ---' + $declaredFunctions = @((Import-PowerShellDataFile -Path $savedManifestPath.FullName).FunctionsToExport) + $exportedFunctions = @($importedModule.ExportedFunctions.Keys) + Write-Host " Declared functions ($($declaredFunctions.Count)): $($declaredFunctions -join ', ')" + Write-Host " Exported functions ($($exportedFunctions.Count)): $($exportedFunctions -join ', ')" + $missingFunctions = @($declaredFunctions | Where-Object { $exportedFunctions -notcontains $_ }) + Confirm-Condition -Condition ($missingFunctions.Count -eq 0) -Description "Every function the manifest declares is exported (missing: $($missingFunctions -join ', '))" + Confirm-Condition -Condition ($exportedFunctions.Count -eq $declaredFunctions.Count) -Description "The module exports exactly the $($declaredFunctions.Count) functions the manifest declares" + + $exportedAliases = @($importedModule.ExportedAliases.Keys) + Write-Host " Exported aliases ($($exportedAliases.Count)): $($exportedAliases -join ', ')" + Confirm-Condition -Condition ($exportedAliases -contains $ExpectedAlias) -Description "The module exports the $ExpectedAlias alias" + + Write-Host '' + Write-Host 'RESULT: install-time dependency resolution passed.' + exit 0 +} catch { + Write-Host '' + Write-Host "RESULT: the isolated probe failed - $($_.Exception.Message)" + Write-Host $_.ScriptStackTrace + exit 1 +} From 62d6dbd55cde6aa617d3b7bc72d20b9c516099bf Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 4 Sep 2026 16:30:43 -0400 Subject: [PATCH 2/4] ci: Keep the engine module directory reachable on PowerShell 7 The first CI run failed on PowerShell 7 while Windows PowerShell 5.1 passed. Dropping the engine's own module directory from the isolated PSModulePath also drops CimCmdlets, so Pester 6.0.0 cannot resolve Get-CimInstance and falls through to looking for a `uname` application. A GitHub Windows runner has uname.exe from Git for Windows on PATH, and Pester then throws "SafeCommands entry for uname does not hold a reference to the proper command." A developer machine without Git's usr\bin on PATH only warns, which is why this never appeared locally. That is an artifact of the isolation, not something a consumer could hit, so keep the engine directory on the PSModulePath for PowerShell 7. It ships with the engine and holds no dependency of the module under test. Windows PowerShell 5.1 keeps the two-entry path it already had: it does not need the engine directory, and adding it there makes the engine restore the machine's other default module paths, which puts the Program Files copy of Pester back in view. The isolation is guaranteed by the explicit visibility assertions either way, not by the contents of that list. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WTjuRainebRsnmcg8fRCKB --- cspell.json | 1 + .../Test-InstallTimeDependency.ps1 | 35 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/cspell.json b/cspell.json index 0ad94bb..5f10049 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,7 @@ "fullclr", "LASTEXITCODE", "psake", + "PSHOME", "MAML" ], "ignoreWords": [ diff --git a/tests/InstallTime/Test-InstallTimeDependency.ps1 b/tests/InstallTime/Test-InstallTimeDependency.ps1 index 929e1cd..c286462 100644 --- a/tests/InstallTime/Test-InstallTimeDependency.ps1 +++ b/tests/InstallTime/Test-InstallTimeDependency.ps1 @@ -10,19 +10,22 @@ PowerShell 7 or Windows PowerShell 5.1 process so that the PSModulePath below is the only module path the process has ever seen. - The isolated PSModulePath contains exactly two directories: + The isolated PSModulePath contains: 1. IsolatedModulePath - empty when the process starts; the Save-Module target. 2. ToolsModulePath - PowerShellGet and PackageManagement only. + 3. On PowerShell 7 only, the engine's own module directory. See Set-IsolatedModulePath for + why it is there on one edition and not the other. Nothing installed on the machine can satisfy a dependency from that path, which is the whole point: on a developer machine and on a CI runner that has just run `./build.ps1 -Bootstrap`, every dependency is already installed, and without this isolation the test would pass no - matter what the manifest declared. + matter what the manifest declared. The visibility assertions below hold that line rather than + trusting the list above. Both modules in the tools path are imported by explicit manifest path rather than by name. Importing PowerShellGet by name on Windows PowerShell 5.1 asks the module auto-loader to - resolve helper modules from a PSModulePath that no longer contains the engine's own module + resolve helper modules from a PSModulePath that does not contain the engine's own module directory, and that fails. .PARAMETER IsolatedModulePath @@ -121,7 +124,24 @@ function Confirm-Condition { function Set-IsolatedModulePath { <# .SYNOPSIS - Points PSModulePath at the isolated save target and the tools directory, and nothing else. + Points PSModulePath at the isolated save target, the tools directory, and on PowerShell 7 the + engine's own module directory - and nothing else. + + .DESCRIPTION + On PowerShell 7 the engine's module directory has to stay reachable. It ships with the engine, + holds no dependency of the module under test, and dropping it takes CimCmdlets with it: Pester + 6.0.0 falls back to looking for a `uname` application when Get-CimInstance cannot be resolved, + and on a GitHub Windows runner it finds `uname.exe` from Git for Windows and then throws + "SafeCommands entry for uname does not hold a reference to the proper command." That is an + artifact of the isolation, not a defect in this module, and no consumer would ever hit it. + + Windows PowerShell 5.1 does not need it - Get-WmiObject resolves without it, so Pester never + reaches that fallback - and must not have it: adding the engine directory there makes the + engine restore the machine's other default module paths as well, which puts the Pester copy + installed under Program Files back in view and destroys the isolation. + + Whichever way it goes, the isolation is guaranteed by the explicit visibility assertions + below, not by this list. .PARAMETER IsolatedModulePath The empty directory that is the Save-Module target. @@ -143,8 +163,13 @@ function Set-IsolatedModulePath { $ToolsModulePath ) + $modulePaths = @($IsolatedModulePath, $ToolsModulePath) + if ($PSVersionTable.PSEdition -eq 'Core') { + $modulePaths += (Join-Path $PSHOME 'Modules') + } + if ($PSCmdlet.ShouldProcess('PSModulePath', 'Restrict to the isolated module paths')) { - $env:PSModulePath = ($IsolatedModulePath, $ToolsModulePath) -join [System.IO.Path]::PathSeparator + $env:PSModulePath = $modulePaths -join [System.IO.Path]::PathSeparator } } From 3b38059de37e977e4c90035ba93e618dc59681f5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 4 Sep 2026 16:33:06 -0400 Subject: [PATCH 3/4] TEMPORARY: make the negative control expect the broken save to succeed Pushed only to show this workflow going red in CI on a real install-time failure. Reverted immediately. Co-Authored-By: Claude Fable 5.1 --- tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 index 85a8280..746ff1a 100644 --- a/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 +++ b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 @@ -502,7 +502,7 @@ try { Write-Host "--- 7c. Save-Module must now fail on $ProbeEdition ---" Invoke-IsolatedProbe -ExecutablePath $probeExecutablePath -ProbeScriptPath $probeScriptPath ` -IsolatedModulePath $paths.NegativeIsolated -ToolsModulePath $paths.Tools -RepositoryName $RepositoryName ` - -ModuleName $ModuleName -RequiredVersion $negativeControlModuleVersion -ExpectSaveToFail + -ModuleName $ModuleName -RequiredVersion $negativeControlModuleVersion $negativeExitCode = $LASTEXITCODE if ($negativeExitCode -ne 0) { throw "The negative control failed on $ProbeEdition with exit code $negativeExitCode; this test cannot be trusted to fail when the manifest is broken." From 4d1dfd1ca68d2d95e38a543e93c71dad61143d73 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 4 Sep 2026 16:35:22 -0400 Subject: [PATCH 4/4] Revert "TEMPORARY: make the negative control expect the broken save to succeed" This reverts commit 3b38059de37e977e4c90035ba93e618dc59681f5. --- tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 index 746ff1a..85a8280 100644 --- a/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 +++ b/tests/InstallTime/Invoke-LocalRepositoryInstallTest.ps1 @@ -502,7 +502,7 @@ try { Write-Host "--- 7c. Save-Module must now fail on $ProbeEdition ---" Invoke-IsolatedProbe -ExecutablePath $probeExecutablePath -ProbeScriptPath $probeScriptPath ` -IsolatedModulePath $paths.NegativeIsolated -ToolsModulePath $paths.Tools -RepositoryName $RepositoryName ` - -ModuleName $ModuleName -RequiredVersion $negativeControlModuleVersion + -ModuleName $ModuleName -RequiredVersion $negativeControlModuleVersion -ExpectSaveToFail $negativeExitCode = $LASTEXITCODE if ($negativeExitCode -ne 0) { throw "The negative control failed on $ProbeEdition with exit code $negativeExitCode; this test cannot be trusted to fail when the manifest is broken."