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..5f10049 100644 --- a/cspell.json +++ b/cspell.json @@ -11,7 +11,11 @@ ], "words": [ "Authenticode", + "coreclr", + "fullclr", + "LASTEXITCODE", "psake", + "PSHOME", "MAML" ], "ignoreWords": [ 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..c286462 --- /dev/null +++ b/tests/InstallTime/Test-InstallTimeDependency.ps1 @@ -0,0 +1,302 @@ +#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: + + 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. 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 does not contain 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, 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. + + .PARAMETER ToolsModulePath + The directory holding only PowerShellGet and PackageManagement. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([void])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $IsolatedModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $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 = $modulePaths -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 +}