From 1bb4f58cc1921c37a44ffa1f89f54e98fceb909a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 13 Aug 2026 11:05:19 -0400 Subject: [PATCH 1/4] fix(build): float Pester, own the test task, and harden bootstrap Brings the template up to the pattern proven in JsmOperations and YouTubeMusicPS. Derived repos inherit whatever ships here, so this is the piece that stops the same four problems being seeded into every new repo. build.depend.psd1 -- Version 'latest' instead of the 6.0.1 pin. Pester 6 discovers each file separately and autoloads Pester to resolve Describe; autoload always picks the highest installed version, so an exact pin below the runner image's version collides with itself: An incompatible version of the Pester.dll assembly is already loaded. build.psake.ps1 -- custom UnitTest task replacing PowerShellBuild's 'Pester' task, gating on failed containers and failed setup/teardown blocks as well as failed tests. A file that dies during discovery generates no tests at all, so a FailedCount-only check reports success while the file never runs. build.ps1 -- install dependencies before importing them, tolerate Register-PSRepository -Default failing on Windows, and compose error detail into a single throw. CI.yaml -- drop the module cache and bound both jobs with timeout-minutes. See the in-file comments for the measurements. Verified by rendering the template with Initialize-Template.ps1 and running the result end to end: 30 passed, exit 0. The setup/teardown gate was confirmed with a deliberately throwing AfterAll: 31 passed, 0 failed, build correctly exited 1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- .github/workflows/CI.yaml | 55 ++++++++------ build.depend.psd1 | 2 +- build.ps1 | 101 +++++++++++++++++++++----- build.psake.ps1 | 146 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 258 insertions(+), 46 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index ac08765..6834701 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -15,6 +15,9 @@ jobs: lint: name: PSScriptAnalyzer Lint runs-on: ubuntu-latest + # Guard against a hung step burning the 6-hour default. Lint normally + # finishes in well under a minute. + timeout-minutes: 10 steps: - uses: actions/checkout@v7 @@ -33,20 +36,25 @@ jobs: echo "is_template=false" >> "$GITHUB_OUTPUT" fi - - name: Cache PowerShell modules - if: steps.template_guard.outputs.is_template == 'false' - id: cache-lint-modules - uses: actions/cache@v6 - with: - path: ~/.local/share/powershell/Modules - key: ${{ runner.os }}-psmodules-lint-${{ hashFiles('build.depend.psd1') }} - restore-keys: | - ${{ runner.os }}-psmodules-lint- - + # No module cache here on purpose -- see the note in the unit-tests job. + # The cache this replaced held 209 bytes: PSScriptAnalyzer ships on the + # runner image, so it was never installed into the cached path and the + # cache only ever restored an empty directory. Install only when the + # image does not already provide it, rather than unconditionally -- the + # module is ~339 MB on disk and re-downloading it every run is far more + # expensive than the cache ever saved. - name: Install PSScriptAnalyzer - if: steps.template_guard.outputs.is_template == 'false' && steps.cache-lint-modules.outputs.cache-hit != 'true' + if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | + if (Get-Module -Name PSScriptAnalyzer -ListAvailable) { + $version = (Get-Module -Name PSScriptAnalyzer -ListAvailable | + Sort-Object -Property Version -Descending | + Select-Object -First 1).Version + Write-Host "PSScriptAnalyzer $version already available; skipping install." + return + } + Set-PSRepository -Name PSGallery -InstallationPolicy Trusted Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser @@ -73,6 +81,13 @@ jobs: unit-tests: name: Unit Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + # Guard against a hung step burning the 6-hour default. A cold run -- + # installing every dependency from the gallery -- completes in about + # 90 seconds. The specific hazard this caught: BuildHelpers' Invoke-Git + # calls WaitForExit() before draining stdout, so on Windows a HEAD commit + # message larger than the 4096-byte pipe buffer deadlocks the build with + # no output at all. Keep merge-commit messages short. + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -99,17 +114,13 @@ jobs: echo "is_template=false" >> "$GITHUB_OUTPUT" fi - - name: Cache PowerShell modules - if: steps.template_guard.outputs.is_template == 'false' - uses: actions/cache@v6 - with: - path: | - ~/Documents/PowerShell/Modules - ~/.local/share/powershell/Modules - key: ${{ runner.os }}-psmodules-${{ hashFiles('build.depend.psd1') }} - restore-keys: | - ${{ runner.os }}-psmodules- - + # No module cache here on purpose. Measured on a derived repo, same tree: + # ubuntu 27s warm / 27s cold, macOS 16s / 26s, Windows 28s / 50s. The + # three jobs run in parallel, so the cache bought at most ~22s of + # wall-clock. Against that it made pull-request runs warm and main runs + # cold, so a PR could pass on a code path main never exercised. + # build.ps1 -Bootstrap gates installation on Invoke-PSDepend -Test, so a + # runner that already satisfies the dependency file does no install work. - name: Build and Test if: steps.template_guard.outputs.is_template == 'false' shell: pwsh diff --git a/build.depend.psd1 b/build.depend.psd1 index a6acd25..eb2b758 100644 --- a/build.depend.psd1 +++ b/build.depend.psd1 @@ -8,7 +8,7 @@ } } 'Pester' = @{ - Version = '6.0.1' + Version = 'latest' Parameters = @{ SkipPublisherCheck = $true } diff --git a/build.ps1 b/build.ps1 index c76be82..a963cf0 100644 --- a/build.ps1 +++ b/build.ps1 @@ -67,52 +67,115 @@ $dependencyFilePath = Join-Path -Path $PSScriptRoot -ChildPath $dependencyFilePa if ($Bootstrap) { $null = PackageManagement\Get-PackageProvider -Name 'NuGet' -ForceBootstrap if ((Test-Path -Path $dependencyFilePath)) { - # Ensure PSGallery is registered and trusted - if (-not (Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue')) { - Register-PSRepository -Default + # Ensure PSGallery is registered and trusted. + # + # Register-PSRepository -Default shells out to nuget.exe, which on some Windows + # runner images fails with: + # + # NuGet.Commands.CommandException: Missing option value for: '-source' + # + # That leaves PSGallery unregistered, and the Set-PSRepository call that used to + # follow it unconditionally then died with "No repository with the name + # 'PSGallery' was found", masking the real cause. Fall back to registering the + # gallery explicitly by URL, and only configure it once it actually exists. + $psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue' + if (-not $psGallery) { + try { + Register-PSRepository -Default -ErrorAction 'Stop' + } + catch { + Write-Verbose "Register-PSRepository -Default failed ($($_.Exception.Message)); registering PSGallery explicitly." -Verbose + } + + $psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue' + if (-not $psGallery) { + $registerParameters = @{ + Name = 'PSGallery' + SourceLocation = 'https://www.powershellgallery.com/api/v2' + InstallationPolicy = 'Trusted' + ErrorAction = 'Stop' + } + Register-PSRepository @registerParameters + $psGallery = Get-PSRepository -Name 'PSGallery' -ErrorAction 'SilentlyContinue' + } + } + + if (-not $psGallery) { + throw 'Could not register the PSGallery repository; build dependencies cannot be installed.' + } + + if ($psGallery.InstallationPolicy -ne 'Trusted') { + Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted' } - Set-PSRepository -Name 'PSGallery' -InstallationPolicy 'Trusted' if (-not (Get-Module -Name 'PSDepend' -ListAvailable)) { Install-Module -Name 'PSDepend' -Scope 'CurrentUser' -Repository 'PSGallery' -Force } Import-Module -Name 'PSDepend' -Verbose:$false - # Try to import existing modules first to avoid installation locks - # Only install if import fails (missing modules or wrong versions) $psDependParameters = @{ Path = $PSScriptRoot Recurse = $False WarningAction = 'SilentlyContinue' - Import = $True Force = $True ErrorAction = 'Stop' } - $importSucceeded = $false + # Install before importing, never the other way round. + # + # This used to attempt an import first and only install if that failed, to avoid + # installation locks. That is safe on a warm cache, where the requested versions + # are already present, but wrong on a cold one: the import pass loads whatever + # version happens to be on the machine already -- typically an older Pester from + # the runner image -- and Pester ships a binary Pester.dll that cannot then be + # replaced in-process by the version the subsequent install brings in: + # + # An incompatible version of the Pester.dll assembly is already loaded. + # The loaded dll version is 5.9.0.0, but at least version 6.1.0 is required + # + # -Test reports whether each dependency is already satisfied without importing + # anything, so it is safe to run before anything is loaded (measured at ~6s here). + # Gate the install on it so a satisfied machine does no install work at all -- + # -Install carries -Force, which re-resolves and re-downloads every dependency. + $dependenciesSatisfied = $false try { - Invoke-PSDepend @psDependParameters - $importSucceeded = $true - Write-Verbose 'Successfully imported existing modules.' -Verbose + $testResults = @(Invoke-PSDepend @psDependParameters -Test -Quiet) + $dependenciesSatisfied = $testResults.Count -gt 0 -and $testResults -notcontains $false } catch { - Write-Verbose "Could not import all required modules: $_" -Verbose - Write-Verbose 'Attempting to install missing or outdated dependencies...' -Verbose + Write-Verbose "Could not determine dependency status: $_" -Verbose } - # If import failed, install the dependencies - if (-not $importSucceeded) { + if (-not $dependenciesSatisfied) { + Write-Verbose 'Installing missing or outdated dependencies...' -Verbose try { Invoke-PSDepend @psDependParameters -Install } catch { - Write-Error "Failed to install and import required dependencies: $_" - Write-Error 'This may be due to locked module files. Please restart the build environment or clear module locks.' + # Compose one message and throw it, rather than emitting several + # Write-Error calls: $ErrorActionPreference is 'Stop' in this script, so + # the first Write-Error terminates and every diagnostic after it -- the + # lock hint, the inner exception -- is silently dropped. + $installError = "Failed to install required dependencies: $($_.Exception.Message)" if ($_.Exception.InnerException) { - Write-Error "Inner exception: $($_.Exception.InnerException.Message)" + $installError += " Inner exception: $($_.Exception.InnerException.Message)" } - throw + $installError += ' This may be due to locked module files; restart the build environment or clear module locks.' + throw $installError + } + } + + try { + Invoke-PSDepend @psDependParameters -Import + Write-Verbose 'Successfully imported required modules.' -Verbose + } + catch { + # Single composed throw -- see the note in the install catch above. + $importError = "Failed to import required dependencies: $($_.Exception.Message)" + if ($_.Exception.InnerException) { + $importError += " Inner exception: $($_.Exception.InnerException.Message)" } + throw $importError } } else { diff --git a/build.psake.ps1 b/build.psake.ps1 index 1f2b47b..e4427f9 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -49,8 +49,7 @@ Task -Name 'Init_Integration' -Description 'Load integration test environment va # PowerShell Gallery release-notes panel shows the curated, user-facing notes (the same # content used for the GitHub release) instead of just a link. Depends on Build so the # staged manifest in ModuleOutDir exists; runs before Publish (see $PSBPublishDependency -# below). Non-fatal if the changelog can't be read or has no entry for the version being -# published, so a release is never blocked. +# below). Non-fatal at every step so a release is never blocked. Task -Name 'UpdateReleaseNotes' -Depends 'Build' -Description 'Set built manifest ReleaseNotes from the matching CHANGELOG.md entry' { $changelogPath = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.md' if (-not (Test-Path -Path $changelogPath)) { @@ -101,5 +100,144 @@ Task -Name 'UpdateReleaseNotes' -Depends 'Build' -Description 'Set built manifes # defaults to depending only on 'Test'). $PSBPublishDependency = @('Test', 'UpdateReleaseNotes') -# Note: -Depends replaces PowerShellBuild's default dependencies, so we must include Pester and Analyze explicitly -Task -Name 'Test' -FromModule 'PowerShellBuild' -MinimumVersion '0.7.3' -Depends 'Init_Integration', 'Pester', 'Analyze' +# Custom Pester task, used instead of PowerShellBuild's built-in 'Pester' task. +# +# Two separate reasons it exists. +# +# 1. Version agreement. PowerShellBuild's Test-PSBuildPester runs +# `Import-Module Pester -MinimumVersion 5.0.0`, which resolves to the *highest* +# installed version. Pester 6 also re-resolves Describe by autoloading during +# its per-file discovery, and autoload likewise picks the highest installed +# version -- so an exact pin is never actually honoured. Whenever the runner +# image ships something newer than the pin, the two collide: +# +# An incompatible version of the Pester.dll assembly is already loaded. +# +# Pester 6.0.1 arrived 2026-07-18 and 6.1.0 on 2026-08-11, breaking CI both +# times with no commit to blame. build.depend.psd1 therefore uses +# Version = 'latest' and this task imports the highest installed version, so +# PSDepend, this task and Pester's autoload all agree and cannot collide. +# Do not narrow either back to an exact version without changing the other. +# +# 2. Failed containers. PowerShellBuild's gate throws only on FailedCount, which +# cannot see a test file that died during discovery -- it generates no tests +# at all, so zero failures reads as success. See the gate below. +$unitTestPreReqs = { + # A psake PreCondition returning $false *skips* the task and lets the build succeed. + # That is the right behaviour for testing being deliberately switched off, and exactly + # the wrong behaviour for a missing test directory -- 'Test' would pass having run + # nothing at all. So only Test.Enabled may skip; anything else throws. + if (-not $PSBPreference.Test.Enabled) { + Write-Warning 'Pester testing is not enabled; skipping UnitTest.' + return $false + } + + if (-not (Test-Path -Path $PSBPreference.Test.RootDir)) { + throw "Test directory [$($PSBPreference.Test.RootDir)] not found, but testing is enabled. Refusing to report success without running tests." + } + + return $true +} + +# Depends on 'Build' because $PSBPreference.Build.ModuleOutDir is only populated once +# PowerShellBuild's Build task has run and staged the module. +Task -Name 'UnitTest' -Depends 'Build' -PreCondition $unitTestPreReqs -Description 'Execute Pester tests, failing on failed containers as well as failed tests' { + # build.depend.psd1 is the single source of truth for the Pester version. + $dependencyFile = Join-Path -Path $PSScriptRoot -ChildPath 'build.depend.psd1' + $pesterVersion = (Import-PowerShellDataFile -Path $dependencyFile).Pester.Version + + if ($pesterVersion -and $pesterVersion -ne 'latest') { + Import-Module -Name 'Pester' -RequiredVersion $pesterVersion -Force -ErrorAction 'Stop' + } + else { + # With 'latest', import the newest installed version. That is also what Pester's + # own autoloading resolves to when it re-resolves Describe during per-file + # discovery -- keeping the two in agreement is precisely what avoids the + # assembly collision, so do not narrow this to a specific version. + $newestPester = Get-Module -Name 'Pester' -ListAvailable | + Sort-Object -Property 'Version' -Descending | + Select-Object -First 1 + if (-not $newestPester) { + throw 'Pester is not installed.' + } + Import-Module -Name $newestPester -Force -ErrorAction 'Stop' + } + Write-Verbose "Using Pester $((Get-Module -Name 'Pester').Version)" -Verbose + + # Remove any previously imported project module and import from the output dir + $moduleManifest = Join-Path -Path $PSBPreference.Build.ModuleOutDir -ChildPath "$($PSBPreference.General.ModuleName).psd1" + Get-Module -Name $PSBPreference.General.ModuleName | Remove-Module -Force -ErrorAction 'SilentlyContinue' + # -ErrorAction Stop so a non-terminating import error fails here rather than letting + # the run continue into Pester against a module that was never loaded. + Import-Module -Name $moduleManifest -Force -ErrorAction 'Stop' + + Push-Location -LiteralPath $PSBPreference.Test.RootDir + + try { + $configuration = [PesterConfiguration]::Default + $configuration.Output.Verbosity = 'Detailed' + $configuration.Run.PassThru = $true + $configuration.Run.Path = $PSBPreference.Test.RootDir + $configuration.TestResult.Enabled = -not [string]::IsNullOrEmpty($PSBPreference.Test.OutputFile) + $configuration.TestResult.OutputPath = $PSBPreference.Test.OutputFile + $configuration.TestResult.OutputFormat = $PSBPreference.Test.OutputFormat + + if ($PSBPreference.Test.CodeCoverage.Enabled) { + $configuration.CodeCoverage.Enabled = $true + # Pester 6 defaults CoveragePercentTarget to 75; this project sets the + # threshold to 0 and enforces coverage via Codecov instead. Carry the + # configured value across or the default silently reintroduces a gate. + # + # The two use different units: PowerShellBuild's Threshold is a fraction + # ("Threshold required to pass code coverage test (.90 = 90%)"), while + # Pester's CoveragePercentTarget is a percentage. Assigning one to the + # other unconverted is a no-op at 0, but would turn a later 0.90 into + # 0.9% and quietly disable the gate. + $configuration.CodeCoverage.CoveragePercentTarget = [double]$PSBPreference.Test.CodeCoverage.Threshold * 100 + if ($PSBPreference.Test.CodeCoverage.Files.Count -gt 0) { + $configuration.CodeCoverage.Path = $PSBPreference.Test.CodeCoverage.Files + } + $configuration.CodeCoverage.OutputPath = $PSBPreference.Test.CodeCoverage.OutputFile + $configuration.CodeCoverage.OutputFormat = $PSBPreference.Test.CodeCoverage.OutputFileFormat + } + + $testResult = Invoke-Pester -Configuration $configuration + + # FailedCount alone is not enough. When a file fails during discovery -- for + # example an empty -ForEach under Pester 6 -- Pester fails the whole container + # and it generates no tests at all: zero passed, zero failed. Gating only on + # FailedCount reports success while that file never ran, which is how ~777 + # tests sat silently disabled in PlexAutomationToolkit. + # Use FailedContainersCount, not `Containers | Where-Object { -not $_.Passed }`. + # A container that dies during discovery still reports Passed = $true on the + # container object, so filtering on it silently matches nothing -- reproducing + # the exact bug this check exists to catch. FailedContainersCount is the + # property Pester actually maintains. + if ($testResult.FailedContainersCount -gt 0) { + $testResult.FailedContainers | ForEach-Object { Write-Warning "Container failed: $($_.Item)" } + throw "$($testResult.FailedContainersCount) test file(s) failed to run. See 'Container failed' above." + } + + # Setup/teardown failures are counted separately again. A BeforeAll that throws + # can leave FailedCount at 0, and a failing AfterAll leaves both FailedCount and + # FailedContainersCount at 0 while the run still reports passing tests -- verified + # against Pester 6.1.0: + # failing AfterAll -> Failed 0, FailedContainers 0, FailedBlocks 1, Passed 1 + if ($testResult.FailedBlocksCount -gt 0) { + $testResult.FailedBlocks | ForEach-Object { Write-Warning "Block failed: $($_.Path -join ' > ')" } + throw "$($testResult.FailedBlocksCount) setup/teardown block(s) failed. See 'Block failed' above." + } + + if ($testResult.FailedCount -gt 0) { + throw 'One or more Pester tests failed' + } + } + finally { + Pop-Location + Remove-Module -Name $PSBPreference.General.ModuleName -ErrorAction 'SilentlyContinue' + } +} + +# Note: -Depends replaces PowerShellBuild's default dependencies. 'UnitTest' above stands in +# for PowerShellBuild's 'Pester' task; 'Analyze' is still PowerShellBuild's. +Task -Name 'Test' -FromModule 'PowerShellBuild' -MinimumVersion '0.7.3' -Depends 'Init_Integration', 'UnitTest', 'Analyze' From 9f866373d2c45f19501a295a88f7ff07c97fc568 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 13 Aug 2026 11:20:38 -0400 Subject: [PATCH 2/4] fix(build): import the selected Pester by path, not by name Import-Module -Name $newestPester passed a PSModuleInfo, which stringifies to its Name, so it imported 'Pester' by name and let PowerShell resolve the version. Verified with 5.7.1 preloaded: it raised the assembly collision this task exists to prevent and left the session on 5.7.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- build.psake.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/build.psake.ps1 b/build.psake.ps1 index e4427f9..331bc87 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -160,7 +160,18 @@ Task -Name 'UnitTest' -Depends 'Build' -PreCondition $unitTestPreReqs -Descripti if (-not $newestPester) { throw 'Pester is not installed.' } - Import-Module -Name $newestPester -Force -ErrorAction 'Stop' + + # Import by path, not by -Name $newestPester. A PSModuleInfo stringifies to + # its Name, so -Name would import 'Pester' by name and resolve the version + # itself -- and if an incompatible Pester is already loaded that re-raises + # the very collision this task exists to prevent: + # + # An incompatible version of the Pester.dll assembly is already loaded. + # + # Verified against 5.7.1 preloaded: -Name left the session on 5.7.1. + # Unload first so the selected version is the only one in play. + Get-Module -Name 'Pester' | Remove-Module -Force -ErrorAction 'SilentlyContinue' + Import-Module -Name $newestPester.Path -Force -ErrorAction 'Stop' } Write-Verbose "Using Pester $((Get-Module -Name 'Pester').Version)" -Verbose From a8a298be12c90041db068aed2a5139924642e5c4 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 13 Aug 2026 13:59:04 -0400 Subject: [PATCH 3/4] fix(build): fail the build when no tests actually execute Every existing gate counts failures, and a run that executes nothing produces zero of all of them -- so it reports success having tested nothing. That is the same hole this task exists to close, in the task itself. Two distinct ways to get there, measured against Pester 6.1.0: empty test directory -> discovered 0, not run 0 -> passed filter matching no test -> discovered 120, not run 120 -> passed TotalCount alone only catches the first, because it counts NotRun. The gate therefore checks executed tests: TotalCount minus NotRunCount. Both counts are cast to int first -- with nothing discovered they come back null, and null arithmetic left the diagnostic message blank. Verified all three directions: filter-matches-nothing exits 1, empty directory exits 1, normal run reports 118 passed and exits 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- .github/workflows/CI.yaml | 17 +++++++++++------ build.psake.ps1 | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 6834701..e308cc0 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -47,21 +47,26 @@ jobs: if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | - if (Get-Module -Name PSScriptAnalyzer -ListAvailable) { - $version = (Get-Module -Name PSScriptAnalyzer -ListAvailable | - Sort-Object -Property Version -Descending | - Select-Object -First 1).Version - Write-Host "PSScriptAnalyzer $version already available; skipping install." + # Pin to the version build.depend.psd1 declares, so lint results here match + # a local ./build.ps1 -Task Analyze. Accepting whatever the runner image + # happens to ship means the two can disagree silently. + $required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version + $installed = Get-Module -Name PSScriptAnalyzer -ListAvailable | + Where-Object { $_.Version -eq $required } + if ($installed) { + Write-Host "PSScriptAnalyzer $required already available; skipping install." return } Set-PSRepository -Name PSGallery -InstallationPolicy Trusted - Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser + Install-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -Scope CurrentUser - name: Run PSScriptAnalyzer if: steps.template_guard.outputs.is_template == 'false' shell: pwsh run: | + $required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version + Import-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -ErrorAction Stop $results = Invoke-ScriptAnalyzer -Path ./{{ModuleName}} -Recurse -Settings PSGallery -ReportSummary $errors = $results | Where-Object { $_.Severity -eq 'Error' } diff --git a/build.psake.ps1 b/build.psake.ps1 index 331bc87..11ae06e 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -242,6 +242,27 @@ Task -Name 'UnitTest' -Depends 'Build' -PreCondition $unitTestPreReqs -Descripti if ($testResult.FailedCount -gt 0) { throw 'One or more Pester tests failed' } + + # A run that executed nothing is not a passing run. Every gate above counts + # failures, and a run with no executed tests produces zero of all of them. + # + # Two distinct ways to get there, and TotalCount alone only catches the first: + # 1. Nothing discovered -- a bad Run.Path, or a tests directory that stopped + # matching *.Tests.ps1. TotalCount is 0. + # 2. Everything discovered but nothing run -- an over-eager filter. Measured + # against Pester 6.1.0 with a filter matching no test name: + # Passed 0 | Failed 0 | Skipped 0 | NotRun 120 | TotalCount 120 + # TotalCount is non-zero, every failure count is 0, and the build passed. + # + # Test.Enabled is the deliberate opt-out and is handled in the PreCondition; + # reaching here having run nothing is a fault either way. + # Cast before subtracting: when nothing is discovered at all these counts come + # back null, and null arithmetic would otherwise leave the message blank. + $discoveredCount = [int]$testResult.TotalCount + $notRunCount = [int]$testResult.NotRunCount + if (($discoveredCount - $notRunCount) -le 0) { + throw "Pester executed no tests under [$($PSBPreference.Test.RootDir)] (discovered $discoveredCount, not run $notRunCount). Refusing to report success without running tests." + } } finally { Pop-Location From 2de7450f00bfbaffd6f1bdcffde7a0de65f5d7ae Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 13 Aug 2026 14:17:31 -0400 Subject: [PATCH 4/4] fix(build): count passed and failed, not merely discovered, tests Follow-up to the previous gate, which used TotalCount minus NotRunCount. That misses a suite where every test is skipped: skipped tests are neither NotRun nor executed-with-a-result, so the subtraction stays positive and the build passes having run nothing. Measured against Pester 6.1.0: empty test directory Total 0 Passed 0 Failed 0 Skipped 0 NotRun 0 filter matching no test Total 120 Passed 0 Failed 0 Skipped 0 NotRun 120 every test -Skip Total 3 Passed 0 Failed 0 Skipped 3 NotRun 0 Filtering on the per-test .Executed property does not distinguish the third case either -- skipped tests report Executed = $true. Only PassedCount plus FailedCount separates a suite that ran from one that did not, so the gate uses that. Verified all three: all-skipped exits 1, empty directory exits 1, and a normal run with two legitimate skips reports 118 passed and exits 0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01G1CarQG8VibNFxw4cN53Zs --- build.psake.ps1 | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/build.psake.ps1 b/build.psake.ps1 index 11ae06e..fcf46c9 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -256,12 +256,21 @@ Task -Name 'UnitTest' -Depends 'Build' -PreCondition $unitTestPreReqs -Descripti # # Test.Enabled is the deliberate opt-out and is handled in the PreCondition; # reaching here having run nothing is a fault either way. - # Cast before subtracting: when nothing is discovered at all these counts come - # back null, and null arithmetic would otherwise leave the message blank. - $discoveredCount = [int]$testResult.TotalCount - $notRunCount = [int]$testResult.NotRunCount - if (($discoveredCount - $notRunCount) -le 0) { - throw "Pester executed no tests under [$($PSBPreference.Test.RootDir)] (discovered $discoveredCount, not run $notRunCount). Refusing to report success without running tests." + # Count tests that actually produced a result. Measured against Pester 6.1.0, + # three ways to reach "nothing ran" that every failure count reads as success: + # + # empty test directory -> Total 0, Passed 0, Failed 0, Skipped 0, NotRun 0 + # filter matching no test -> Total 120, Passed 0, Failed 0, Skipped 0, NotRun 120 + # every test -Skip -> Total 3, Passed 0, Failed 0, Skipped 3, NotRun 0 + # + # TotalCount minus NotRunCount misses the third, and so does filtering on the + # per-test .Executed property -- skipped tests report Executed = $true. Only + # passed-plus-failed distinguishes a suite that ran from one that did not. + # Casts are deliberate: with nothing discovered these come back null. + $ranCount = [int]$testResult.PassedCount + [int]$testResult.FailedCount + if ($ranCount -le 0) { + $counts = "discovered $([int]$testResult.TotalCount), skipped $([int]$testResult.SkippedCount), not run $([int]$testResult.NotRunCount)" + throw "Pester ran no tests under [$($PSBPreference.Test.RootDir)] ($counts). Refusing to report success without running tests." } } finally {