From 6035c1bbd47f59db86cf487a07f737431510133c Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 26 Aug 2026 19:16:38 -0400 Subject: [PATCH 1/2] feat!: Raise the Pester floor to 6.0.0 and drop the 5.x matrix Closes #172. Part of #120 (Phase 3). BREAKING CHANGE: Pester 5.x is no longer supported. RequiredModules is enforced at import, so a consumer with only Pester 5.x installed cannot import the module, and Test-PSBuildPester refuses to run under an already-loaded 5.x. Same principle as the psake floor in #181: the declared minimum should be what CI actually tests. Nothing exercised a Pester 5 consumer except the matrix leg, which existed only to keep that claim honest. Two real defects go with it. Test-PSBuildPester declared a 5.0.0 minimum it could never honor. Verified by downloading that version: Pester 5.0.0 does not export New-PesterConfiguration and has no Run.SkipRemainingOnFailure, and the function calls both. The guard admitted versions that then failed with a CommandNotFoundException instead of the clear error it exists to produce. The requirements.psd1 pin moves 6.0.0 -> 6.1.0. Note the original #172 premise was wrong and is corrected on the issue: CI does honor the pin, running exactly 6.0.0 on all four legs. What is true is narrower -- the pin holds only because 6.0.0 is the highest Pester on the runner, and a newer one in a future image would silently override it. Dropping 5.x support removes machinery rather than adding it: requirements.pester-matrix.psd1 is deleted, build.ps1 loses its second Invoke-PSDepend call, and the Test-PSBuildPester matrix collapses from two majors to one. The discovery loop is kept rather than hardcoded, so a second supported major can return without rebuilding it, and it now throws when no supported major is installed instead of silently producing no tests. Consumer cost is smaller than a major bump implies: Pester 6 keeps the v5 Should -Be assertions, since Should.DisableV5 defaults to false. The one behavioral change worth flagging is Run.FailOnNullOrEmptyForEach, which now defaults to true. Suite: 463 passed, 0 failed. The drop from 472 is the removed Pester 5 matrix leg, not a regression. Test-ModuleManifest validates against the raised floor. The one analyzer finding on Test-PSBuildPester is pre-existing on main. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- CHANGELOG.md | 14 +++++ PowerShellBuild/PowerShellBuild.psd1 | 2 +- PowerShellBuild/Public/Test-PSBuildPester.ps1 | 4 +- PowerShellBuild/en-US/Messages.psd1 | 2 +- build.ps1 | 3 - docs/migration-v0.8-to-v1.0.md | 55 +++++++++++++++++++ .../repository-specific.instructions.md | 12 ++-- requirements.pester-matrix.psd1 | 18 ------ requirements.psd1 | 2 +- tests/Test-PSBuildPester.tests.ps1 | 23 +++++--- 10 files changed, 94 insertions(+), 41 deletions(-) delete mode 100644 requirements.pester-matrix.psd1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da6b45..14a0705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed +- [**#172**](https://github.com/psake/PowerShellBuild/issues/172) + **Breaking:** the consumer Pester floor in `RequiredModules` is raised from + `5.6.1` to `6.0.0`, and `Test-PSBuildPester` refuses to run under anything + older. Pester 5.x is no longer supported. As with the psake floor, the lower + minimum asserted support nothing verified — CI runs Pester 6 and nothing + exercised a 5.x consumer. Pester 6 keeps the v5 `Should -Be` assertions + (`Should.DisableV5` defaults to `$false`), so most suites need no changes; the + one behavioral change to watch is `Run.FailOnNullOrEmptyForEach`, which now + defaults to `$true`. Also fixes a guard that could never work: + `Test-PSBuildPester` declared a `5.0.0` minimum while calling + `New-PesterConfiguration` and `Run.SkipRemainingOnFailure`, neither of which + exists in 5.0.0. See the + [v0.8 → v1.0 migration guide](docs/migration-v0.8-to-v1.0.md). + - [**#166**](https://github.com/psake/PowerShellBuild/issues/166) **Breaking:** the consumer psake floor in `RequiredModules` is raised from `4.9.0` to `5.0.4`. psake 4.x is no longer supported. `RequiredModules` is diff --git a/PowerShellBuild/PowerShellBuild.psd1 b/PowerShellBuild/PowerShellBuild.psd1 index a0b539b..3bb6ba1 100644 --- a/PowerShellBuild/PowerShellBuild.psd1 +++ b/PowerShellBuild/PowerShellBuild.psd1 @@ -10,7 +10,7 @@ CompatiblePSEditions = @('Desktop', 'Core') RequiredModules = @( @{ModuleName = 'BuildHelpers'; ModuleVersion = '2.0.16' } - @{ModuleName = 'Pester'; ModuleVersion = '5.6.1' } + @{ModuleName = 'Pester'; ModuleVersion = '6.0.0' } @{ModuleName = 'psake'; ModuleVersion = '5.0.4' } ) FunctionsToExport = @( diff --git a/PowerShellBuild/Public/Test-PSBuildPester.ps1 b/PowerShellBuild/Public/Test-PSBuildPester.ps1 index 5a3b293..33dfca3 100644 --- a/PowerShellBuild/Public/Test-PSBuildPester.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildPester.ps1 @@ -72,9 +72,9 @@ function Test-PSBuildPester { # versions are installed side by side. Only load Pester ourselves when none is loaded. $loadedPester = Get-Module -Name Pester if (-not $loadedPester) { - $loadedPester = Import-Module -Name Pester -MinimumVersion 5.0.0 -ErrorAction Stop -PassThru + $loadedPester = Import-Module -Name Pester -MinimumVersion 6.0.0 -ErrorAction Stop -PassThru } - if ($loadedPester.Version -lt [version]'5.0.0') { + if ($loadedPester.Version -lt [version]'6.0.0') { throw ($LocalizedData.PesterVersionNotSupported -f $loadedPester.Version) } diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 48c8516..0feb84b 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -16,7 +16,7 @@ FolderDoesNotExist=Folder does not exist: {0} PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module PesterTestsFailed=One or more Pester tests failed -PesterVersionNotSupported=Pester version [{0}] is loaded, but Test-PSBuildPester requires Pester 5.0.0 or newer. +PesterVersionNotSupported=Pester version [{0}] is loaded, but Test-PSBuildPester requires Pester 6.0.0 or newer. CodeCoverage=Code Coverage Type=Type CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] diff --git a/build.ps1 b/build.ps1 index 4237efb..8529de6 100644 --- a/build.ps1 +++ b/build.ps1 @@ -42,9 +42,6 @@ if ($Bootstrap.IsPresent) { } Import-Module -Name PSDepend -Verbose:$false Invoke-PSDepend -Path './requirements.psd1' -Install -Import -Force -WarningAction SilentlyContinue - # Install-only, never imported: importing a second Pester major into this session would - # crash with a Pester.dll version conflict. See requirements.pester-matrix.psd1. - Invoke-PSDepend -Path './requirements.pester-matrix.psd1' -Install -Force -WarningAction SilentlyContinue } # Execute psake task(s) diff --git a/docs/migration-v0.8-to-v1.0.md b/docs/migration-v0.8-to-v1.0.md index 192d430..44be184 100644 --- a/docs/migration-v0.8-to-v1.0.md +++ b/docs/migration-v0.8-to-v1.0.md @@ -43,6 +43,8 @@ One line per break; follow the link for details and migration steps. drops, and convert orphaned documents by hand. - [psake 4.x is no longer supported; the floor is now 5.0.4](#psake-4x-is-no-longer-supported-the-floor-is-now-504) — psake users must upgrade to 5.0.4+; Invoke-Build users are unaffected. +- [Pester 5.x is no longer supported; the floor is now 6.0.0](#pester-5x-is-no-longer-supported-the-floor-is-now-600) + — Pester 6 keeps the `Should -Be` syntax, so most suites need no changes. > More entries will follow as the remaining Phase 2 work lands. @@ -656,6 +658,59 @@ tests that had not been running. If your Pester tests call Decision and evidence in [#166](https://github.com/psake/PowerShellBuild/issues/166). +### Pester 5.x is no longer supported; the floor is now 6.0.0 + +`RequiredModules` requires **Pester 6.0.0 or newer**, previously 5.6.1, and +`Test-PSBuildPester` refuses to run under anything older. + +`RequiredModules` is enforced when the module is imported, so with only +Pester 5.x installed, `Import-Module PowerShellBuild` fails outright. + +**Migration:** + +```powershell +Install-Module -Name Pester -MinimumVersion 6.0.0 -Repository PSGallery -SkipPublisherCheck +``` + +`-SkipPublisherCheck` is needed on Windows PowerShell, where an older +Microsoft-signed Pester ships in the box. + +**Detection:** `Import-Module PowerShellBuild` fails naming `Pester` and +version `6.0.0`. If a Pester 5.x is already loaded in the session when +`Test-PSBuildPester` runs, it throws instead: `Pester version [5.9.1] is +loaded, but Test-PSBuildPester requires Pester 6.0.0 or newer.` + +**What upgrading Pester costs you.** Less than a major version bump usually +implies, because Pester 6 kept the v5 assertion syntax: + +- **`Should -Be` and the rest of the v5 assertions still work.** Pester 6's + `Should.DisableV5` configuration option defaults to `$false`, so existing + tests keep running unchanged. You are not required to adopt the new + `Should-Be` family. +- **An empty or `$null` `-ForEach` now fails.** `Run.FailOnNullOrEmptyForEach` + defaults to `$true` in Pester 6, where Pester 5 skipped silently. If a + data-driven test is fed an empty collection, discovery now errors instead of + quietly generating no tests. That is usually a bug being surfaced — a test + that never ran and never said so — but it can turn a green suite red. + Set `-AllowNullOrEmptyForEach` on the specific `It`/`Context` that can + legitimately be empty. +- Pester 6 supports Windows PowerShell 5.1 and PowerShell 7.4+, matching + PowerShellBuild's own support floor, so it adds no engine constraint. + +**Why the floor moved.** The same reason as the psake floor: the declared +minimum should be what is actually tested. CI runs Pester 6 and nothing else +verifies a 5.x consumer, so 5.6.1 asserted support nothing proved. + +Two related defects are fixed in the same change. `Test-PSBuildPester` +declared a `5.0.0` minimum it could never honor — Pester 5.0.0 does not export +`New-PesterConfiguration` and has no `Run.SkipRemainingOnFailure`, both of +which the function calls — so the guard admitted versions that failed later +with a confusing `CommandNotFoundException`. And the exact pin in +`requirements.psd1` moved to the current release. + +Decision and evidence in +[#172](https://github.com/psake/PowerShellBuild/issues/172). + ## Adding an entry (for PR contributors) Every breaking-change PR that lands in v1.0.0 must add an entry here for diff --git a/instructions/repository-specific.instructions.md b/instructions/repository-specific.instructions.md index ad048f7..388e2d8 100644 --- a/instructions/repository-specific.instructions.md +++ b/instructions/repository-specific.instructions.md @@ -40,7 +40,7 @@ PowerShellBuild/ │ ├── build.properties.ps1 # $PSBPreference (canonical config hashtable) │ ├── psakeFile.ps1 # Tasks consumers import │ └── IB.tasks.ps1 # Invoke-Build entry (aliased as PowerShellBuild.IB.Tasks) -├── tests/ # Pester 5+ tests +├── tests/ # Pester 6+ tests │ └── 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 @@ -236,11 +236,11 @@ versions — and installed via **PSDepend** when `./build.ps1 -Bootstrap` runs: ## Testing -Tests live in `tests/` and run on **Pester 6.0.0** (pinned exactly in `requirements.psd1`; -see psake/PowerShellBuild#17 for the targeting decision). Write assertions with the classic -`Should -Be` syntax — valid in both Pester 5 and 6 — rather than the Pester 6-only `Should-*` -assertion family, so tests remain backportable. The shipped module's own Pester compatibility -floor (5.0.0 in `Test-PSBuildPester`) is a separate contract and is unchanged. +Tests live in `tests/` and run on **Pester 6.1.0** (pinned in `requirements.psd1`; see +psake/PowerShellBuild#17 for the targeting decision). Write assertions with the classic +`Should -Be` syntax rather than the Pester 6-only `Should-*` assertion family — Pester 6 +still accepts it, and it keeps the diff against existing tests small. The shipped module's +own floor is **Pester 6.0.0** as of psake/PowerShellBuild#172, matching what CI tests. - Always build the module before running Pester directly — running against source can produce incorrect results. Prefer `./build.ps1 -Task Test` over a raw `Invoke-Pester` call. diff --git a/requirements.pester-matrix.psd1 b/requirements.pester-matrix.psd1 deleted file mode 100644 index cd58532..0000000 --- a/requirements.pester-matrix.psd1 +++ /dev/null @@ -1,18 +0,0 @@ -# Install-only dependencies. The bootstrap in build.ps1 installs this file WITHOUT importing: -# these modules exist so the Test-PSBuildPester integration tests can pin them inside -# subprocesses, and importing a second Pester major into the bootstrap session would crash -# with a Pester.dll version conflict against the Pester version from requirements.psd1. -@{ - PSDependOptions = @{ - Target = 'CurrentUser' - } - # Newest Pester 5.x, installed side by side with the pinned 6.x so the shipped - # Test-PSBuildPester function is verified against both supported majors. - PesterLegacy = @{ - Name = 'Pester' - Version = '5.9.0' - Parameters = @{ - SkipPublisherCheck = $true - } - } -} diff --git a/requirements.psd1 b/requirements.psd1 index bc837ae..ce98f09 100755 --- a/requirements.psd1 +++ b/requirements.psd1 @@ -4,7 +4,7 @@ } BuildHelpers = '2.0.16' Pester = @{ - Version = '6.0.0' + Version = '6.1.0' Parameters = @{ SkipPublisherCheck = $true } diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 index 7a6c56e..feb756d 100644 --- a/tests/Test-PSBuildPester.tests.ps1 +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -1,21 +1,23 @@ # Integration tests for Test-PSBuildPester (psake/PowerShellBuild#102). # # Test-PSBuildPester wraps Invoke-Pester, so these tests are Pester-testing-Pester. Every -# invocation runs in a Start-Job subprocess: two Pester versions cannot coexist in one session, -# and the subprocess lets each test pin the inner Pester version independently of the outer -# framework. The scenarios run against every installed Pester major (5.x and 6.x) to verify the -# shipped function keeps supporting Pester 5 consumers. The job runner itself lives in -# fixtures/FixtureHelpers.psm1, shared with the other test files that need a fresh session. +# invocation runs in a Start-Job subprocess, which keeps the inner run's Pester state out of the +# outer framework's. The job runner lives in fixtures/FixtureHelpers.psm1, shared with the other +# test files that need a fresh session. +# +# This was a two-major matrix until #172 raised the floor to Pester 6. It still discovers the +# version rather than hardcoding one, so the loop below is what would grow back if a second +# supported major ever returned. # # The crash fixtures are generated into $TestDrive at runtime, never checked in, so the # repository's own Pester run can never discover them (see #97 for the convention). BeforeDiscovery { - # Newest installed Pester of each supported major version. CI installs 6.x (Pester) and - # 5.x (PesterLegacy) via requirements.psd1; locally, absent majors simply produce fewer - # matrix legs. + # Newest installed Pester of each supported major. Pester 6 is the only supported major + # since #172; an absent one simply produces no matrix legs, which fails loudly below rather + # than passing with nothing run. $script:innerPesterVersions = @( - foreach ($majorVersion in 5, 6) { + foreach ($majorVersion in 6) { $newestOfMajor = Get-Module -Name 'Pester' -ListAvailable | Where-Object { $_.Version.Major -eq $majorVersion } | Sort-Object -Property 'Version' -Descending | @@ -25,6 +27,9 @@ BeforeDiscovery { } } ) + if ($script:innerPesterVersions.Count -eq 0) { + throw 'No supported Pester major (6.x) is installed; Test-PSBuildPester cannot be verified.' + } } Describe 'Test-PSBuildPester' { From e2da4d3d9685c4111a4385043165721d1b864372 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 26 Aug 2026 19:30:04 -0400 Subject: [PATCH 2/2] test: Fail the build when the Pester floor drifts between manifest and guard The floor is stated in three places -- RequiredModules, and twice inside Test-PSBuildPester -- plus the message the guard throws. They drifted and nobody noticed for months: the manifest said 5.6.1 while the function said 5.0.0, a version that cannot run the function at all. That is the defect this pull request fixes, and nothing would have caught it recurring. The alternative was to read the floor from the manifest at runtime. Verified as workable and cheap -- Import-PowerShellDataFile on /.psd1 returns 6.0.0, about 0.4 ms a call -- but rejected. It adds a lookup that returns nothing when the module is loaded from an unusual path, and a floor of $null makes "$version -lt $null" false, so the guard would vanish silently. That is the same shape as Get-Command -Module $null applying no filter (#174) and the empty ExternalHelpFile aborting a MAML export. A hardcoded fallback would not help either: the constant would still exist, alongside a new branch that can pick the wrong one. Note $m.RequiredModules is not a route to this at all -- it reports the loaded version, not the declared minimum, so a guard built on it would compare Pester against itself. Asserting the agreement catches drift when it is introduced, in CI, with no runtime coupling and no way to fail open. Proven to work: temporarily desyncing the guard to 5.0.0 fails exactly one test, "uses the manifest floor everywhere the function states it". One of the four tests guards the test itself. If the function is refactored so neither the -MinimumVersion nor the [version] literal pattern matches, an empty match set would satisfy the comparison and prove nothing, so the count is asserted first. Also documents why "honors the Pester version that is already loaded" now skips in CI. It needs two installed Pester versions to distinguish "used the loaded one" from "imported the newest", and dropping the 5.x matrix left one. Restoring it would mean reinstating install-only machinery this pull request deletes, so it is skipped deliberately rather than by oversight. Suite: 467 passed, 0 failed. Analyzer clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012AKaM9i6NyMFDcJNeC34h5 --- tests/Test-PSBuildPester.tests.ps1 | 73 ++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 index feb756d..43ccb4f 100644 --- a/tests/Test-PSBuildPester.tests.ps1 +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -32,6 +32,72 @@ BeforeDiscovery { } } +Describe 'Pester version floor' { + + # The floor is stated in three places: the manifest's RequiredModules, and twice inside + # Test-PSBuildPester. They must agree, and for months they did not -- the manifest said + # 5.6.1 while the function said 5.0.0, a version that cannot run the function at all + # because Pester 5.0.0 has no New-PesterConfiguration (psake/PowerShellBuild#172). + # + # The function deliberately keeps a literal rather than reading the manifest at runtime. + # Reading it would introduce a lookup that returns nothing when the module is loaded from + # an unusual path, and a floor of $null makes "$version -lt $null" false -- the guard would + # vanish silently. This test enforces the agreement at the only moment anyone can act on + # it: when the drift is introduced. + + BeforeAll { + $script:moduleRoot = Split-Path -Path $PSScriptRoot -Parent + $script:sourceManifestPath = [IO.Path]::Combine( + $script:moduleRoot, 'PowerShellBuild', 'PowerShellBuild.psd1' + ) + $script:sourceFunctionPath = [IO.Path]::Combine( + $script:moduleRoot, 'PowerShellBuild', 'Public', 'Test-PSBuildPester.ps1' + ) + + $script:declaredFloor = ( + (Import-PowerShellDataFile -Path $script:sourceManifestPath).RequiredModules | + Where-Object { $_.ModuleName -eq 'Pester' } + ).ModuleVersion + + # Both forms the floor takes in the function: the -MinimumVersion argument on the + # import, and the [version] literal in the comparison. + $functionSource = Get-Content -Path $script:sourceFunctionPath -Raw + $script:guardVersion = @( + [regex]::Matches($functionSource, "-MinimumVersion\s+(?\d+\.\d+\.\d+)") + + [regex]::Matches($functionSource, "\[version\]'(?\d+\.\d+\.\d+)'") + ).ForEach({ $_.Groups['version'].Value }) + } + + It 'declares a Pester floor in the module manifest' { + $script:declaredFloor | Should -Not -BeNullOrEmpty + } + + It 'states the floor in Test-PSBuildPester at least twice' { + # Guards the test itself: if the function is refactored so neither pattern matches, + # an empty set would otherwise satisfy the comparison below and prove nothing. + $script:guardVersion.Count | Should -BeGreaterOrEqual 2 + } + + It 'uses the manifest floor everywhere the function states it' { + foreach ($version in $script:guardVersion) { + $version | Should -Be $script:declaredFloor + } + } + + It 'names the same floor in the message the guard throws' { + # Read as text rather than with Import-PowerShellDataFile: Messages.psd1 is a + # ConvertFrom-StringData document, not a hashtable literal. + $messagesPath = [IO.Path]::Combine( + $script:moduleRoot, 'PowerShellBuild', 'en-US', 'Messages.psd1' + ) + $messageLine = Get-Content -Path $messagesPath | + Where-Object { $_ -like 'PesterVersionNotSupported=*' } + + $messageLine | Should -Not -BeNullOrEmpty + $messageLine | Should -Match ([regex]::Escape($script:declaredFloor)) + } +} + Describe 'Test-PSBuildPester' { BeforeAll { @@ -240,6 +306,13 @@ Describe 'Coverage target' { # Regression: an unconditional Import-Module Pester -MinimumVersion 5.0.0 loaded the # newest installed Pester on top of an already-loaded older one, which crashes with a # Pester.dll version conflict when 5.x and 6.x are installed side by side. + # + # This skips in CI as of #172. It needs two installed Pester versions to tell + # "used the loaded one" apart from "imported the newest", and dropping the 5.x + # matrix left exactly one. It still runs on a developer machine with more than one + # Pester installed. Restoring it in CI would mean installing a second version that + # is never imported -- the machinery #172 deleted -- so it is left skipped + # deliberately rather than by oversight. $result = Invoke-TestPSBuildPesterInJob -ModulePath $script:builtModulePath -InnerPesterVersion $script:oldestInnerVersion -Path $script:healthyPath $result.Threw | Should -BeFalse