By the time I pushed the OrphanedSid branch to GitHub, it had passed 2,412 tests on both shells locally and a full run against a lab domain controller. The quality-gates workflow was copied from my other Gallery modules, so I wasn’t expecting it to find much.
This was a way to see my old style of thinking against my new one: Find-UnknownSID was the first script I tried Pester on, and the quality-gates workflow has been polished by everything I’ve learned since.
It has three jobs. Tests and analysis runs PSScriptAnalyzer and Pester with coverage on PowerShell 7. Windows PowerShell 5.1 runs the suite again under 5.1. Command help runs on Ubuntu and checks that the compiled help matches its Markdown. The first run, at 21:42 UTC, came back like this:
The two Pester jobs had 44 and 55 failures:
Help files with nothing in them
The help job failed on its last check:
| |
The command help comes from PlatyPS Markdown under docs/OrphanedSid/, compiled to MAML in en-US/. All four Markdown files had been committed at zero bytes. A script that rewrote the troubleshooting paths in them had assigned the result of a regex call that threw, and then written $null back to each file.
Build-Help.ps1 passed on my machine anyway, for two reasons. Measure-PlatyPSMarkdown classifies an empty file as nothing at all, so the structure, placeholder and link checks had nothing to reject. And the compile step copied whatever was in maml/:
Locally, maml/ still held a build from before the files were emptied, so the copy found a real help file and the build looked fine. The Ubuntu job started from a clean checkout, where there was nothing to copy.
The Markdown was regenerated from the three public functions’ original comment-based help. Build-Help.ps1 now deletes maml/ before it compiles:
It also counts what PlatyPS recognized against what the module exports, so a folder of empty files can’t pass again:
| |
Shuffled test order
Both Pester jobs shuffle the suite and print the seed, so you can replay any failing order with Run.ShuffleSeed:
I’d run the suite in its normal order at home. On the runners, a lot of the failures looked like this:
Pester can only mock a command that exists. Four test files mocked Write-StructuredLog, and a fifth mocked Write-SecurityStructuredLogEntry, without defining either one. In declaration order an earlier file had already left a definition lying around, so the mock had something to attach to. Shuffled, the file sometimes ran first.
Other tests had quieter dependencies on order. The end-to-end discovery tests let one test’s scenario reach the next, a performance context reused an object built in another context, and the logging configuration tests checked a file-load state that other tests in the same file had changed. Each fix was small. The rule they add up to is in the commit: every test file passes alone in a fresh process with shuffling on, and the full suite passes on both shells with the seeds from the failed runs.
I’ve been enjoying shuffle since it showed up in Pester 6.1 (still marked experimental). It’s been a great way to find out whether my tests have hidden dependencies on each other.
TEMP on the runner is a short path
Several Test-PathTraversal tests failed only on GitHub. Their setup built paths from $env:TEMP, and on the Windows runners that’s an 8.3 short path. The first run’s log shows it:
| |
The function under test returns resolved, long paths. If your test builds its expected value from $env:TEMP, you’re comparing C:\Users\RUNNER~1\... against C:\Users\runneradmin\..., two spellings of the same folder. Get-Item expands the short form:
The next run’s log has the long name, C:\Users\runneradmin\AppData\Local\Temp\..., in the same place.
Stop, twice
The rest of the Pester failures came from $ErrorActionPreference. The workflow set it to Stop before calling Invoke-Pester, which seemed like a reasonable thing to do in CI. The tests inherit it, though, and so does every function they call. A function that reports one bad item with Write-Error and carries on becomes a function that stops at the first bad item.
ScriptConfiguration.ValidateConfiguration() was one of those. It reported each finding with Write-Error and then returned $false, and its tests checked for the $false:
Under Stop, the Write-Error threw before the method could return. The method answers with $false and lets the caller decide, so its findings are warnings now:
The workflow lost its Stop line in the third run. One test still failed, and only under 5.1:
That test passes a path with a pipe in it. On Windows PowerShell 5.1, .NET Framework rejects the pipe outright, so Test-PathTraversal lands in its catch, reports the problem with Write-Error, and moves on. PowerShell 7 on .NET doesn’t complain about the pipe at all, which is why the test only failed in one job. The same path on both shells on my machine:
| |
So the Write-Error was expected, and something was still turning it into a stop. The answer is in the runner’s design notes: for shell: pwsh and shell: powershell, the runner prepends $ErrorActionPreference = 'stop' to every step’s script. Deleting my line changed nothing. If you run Pester from a pwsh or powershell step, your tests are running under Stop whether your script says so or not.
A function that reports a problem and keeps going behaves like this with and without that preference:
Both Pester steps now set it back before the suite runs:
| |
The gates after the run check FailedCount and coverage themselves and throw, so the step still fails when it should.
A .gitignore rule one folder too wide
Fixing the restore path in the live run had come with a new test file, Tests/Unit/Private/Backup/Restore-IndividualObject.Tests.ps1. It wasn’t in the repository. The .gitignore had this:
Those are meant for the folders a run writes into the current directory. A bare Backup/ matches a folder named Backup at any depth, so it matched Tests/Unit/Private/Backup/ too. Older files in that folder were already tracked, and git kept tracking them, so nothing looked wrong. Only the new file was hidden. Anchoring both rules to the repository root fixed it:
The third run is the first with that file in it, so its pass count went from 2,413 to 2,418.
The coverage gate
The second run’s PowerShell 7 job passed every test and failed anyway:
The 80% gate came with the workflow, and this suite had never been near it. For now it became a ratchet at 60%, with a note in CLAUDE.md that the number only moves up.
The fourth run, at 23:34 on a595f92, was the first green one: all three jobs, both shells, shuffled.
