From f83e8b12a465f7530143149185a7d0c3b893cf42 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:08:50 -0400 Subject: [PATCH 1/2] fix: Stop docs and test tasks unloading the caller's modules Build-PSBuildMarkdown and Test-PSBuildPester both ended with Remove-Module -Name $ModuleName, which removes every loaded module of that name -- including a copy the caller loaded and neither function ever imported. Both sit on default task chains, so this reached essentially every consumer. Test-PSBuildPester was the worse of the two: its import is conditional on -ImportModule, which defaults to $false, while its removal was not, so on the default Test chain it removed a module it had never touched. Both now record what was loaded before they import, remove only the instance they created, and restore what they displaced. Scoping the removal alone is not enough -- Import-Module -Force independently evicts a copy loaded from the same path -- and -Force has to stay, because without it the PSModuleInfo reports the previous build's exported commands and the generated markdown would document a stale surface. Build-PSBuildMarkdown also drops -Global from its import. PlatyPS resolves the module through the PSModuleInfo it is handed rather than by name, so there is no session-state lookup for -Global to serve; the generated markdown is byte-for-byte identical without it. Resolves #221 Resolves #222 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- .../Public/Build-PSBuildMarkdown.ps1 | 29 ++- PowerShellBuild/Public/Test-PSBuildPester.ps1 | 38 +++- tests/Build-PSBuildHelp.tests.ps1 | 99 ++++++++++ tests/Test-PSBuildPester.tests.ps1 | 97 ++++++++++ tests/fixtures/FixtureHelpers.psm1 | 181 ++++++++++++++++++ 5 files changed, 435 insertions(+), 9 deletions(-) diff --git a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 index 24fc461..4493149 100644 --- a/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildMarkdown.ps1 @@ -51,7 +51,22 @@ function Build-PSBuildMarkdown { [bool]$UseFullTypeName ) - $moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Global -Force -PassThru + # Record every copy of the module the caller already had loaded, so the finally block can + # put the session back the way it was found. Two separate things reach a caller's module + # otherwise: -Force evicts a copy loaded from this same path before the finally block is + # ever reached, and the removal that used to run there was by name, which takes every + # loaded copy regardless of who imported it or from where (psake/PowerShellBuild#221). + # + # -Force nevertheless stays. Without it, importing an already-loaded module is a no-op that + # returns the cached PSModuleInfo, so ExportedCommands would describe the previous build's + # command surface and the generated markdown would silently document a stale module. + # + # -Global is deliberately absent. Microsoft.PowerShell.PlatyPS resolves the module through + # the PSModuleInfo passed as -ModuleInfo below rather than by name, so it never performs a + # session-state lookup that -Global would serve. The generated markdown is byte-for-byte + # identical without it, and the import stops reaching into the caller's session state. + $previouslyLoadedModule = @(Get-Module -Name $ModuleName) + $moduleInfo = Import-Module "$ModulePath/$ModuleName.psd1" -Force -PassThru try { if ($moduleInfo.ExportedCommands.Count -eq 0) { @@ -149,6 +164,16 @@ function Build-PSBuildMarkdown { } catch { Write-Error ($LocalizedData.FailedToGenerateMarkdownHelp -f $_) } finally { - Remove-Module $ModuleName + # Remove only the instance this function imported. The zero-export path above returns + # from inside the try block, which still runs this, so a module with no exported + # commands generates nothing and leaves the caller's session untouched all the same. + Remove-Module -ModuleInfo $moduleInfo -Force -ErrorAction SilentlyContinue + + # Restored into the global session state, because that is where a caller's copy lives. + # An import issued from inside this module without -Global would only reach + # PowerShellBuild's own session state, and the caller would still see nothing. + foreach ($restoredModule in $previouslyLoadedModule) { + Import-Module -ModuleInfo $restoredModule -Global -ErrorAction SilentlyContinue + } } } diff --git a/PowerShellBuild/Public/Test-PSBuildPester.ps1 b/PowerShellBuild/Public/Test-PSBuildPester.ps1 index 33dfca3..25ff647 100644 --- a/PowerShellBuild/Public/Test-PSBuildPester.ps1 +++ b/PowerShellBuild/Public/Test-PSBuildPester.ps1 @@ -78,14 +78,27 @@ function Test-PSBuildPester { throw ($LocalizedData.PesterVersionNotSupported -f $loadedPester.Version) } + # Nothing is imported unless -ImportModule is passed, so these stay empty on the default + # path and the finally block below has nothing to undo (psake/PowerShellBuild#222). + $previouslyLoadedModule = @() + $importedModule = $null + try { if ($ImportModule) { if (-not (Test-Path $ModuleManifest)) { Write-Error ($LocalizedData.UnableToFindModuleManifest -f $ModuleManifest) } else { - # Remove any previously imported project modules and import from the output dir - Get-Module $ModuleName | Remove-Module -Force -ErrorAction SilentlyContinue - Import-Module $ModuleManifest -Force + # Remove any previously imported project modules and import from the output + # dir, so the tests run against the module that was just built rather than a + # copy the session happened to be holding. What the caller had loaded is + # recorded first and restored in the finally block; ModuleName guards the + # lookup because Get-Module rejects an empty -Name with a parameter-binding + # error that -ErrorAction SilentlyContinue cannot suppress. + if ($ModuleName) { + $previouslyLoadedModule = @(Get-Module -Name $ModuleName) + $previouslyLoadedModule | Remove-Module -Force -ErrorAction SilentlyContinue + } + $importedModule = Import-Module -Name $ModuleManifest -Force -PassThru } } @@ -153,10 +166,21 @@ function Test-PSBuildPester { } } finally { Pop-Location - # ModuleName is optional; Remove-Module with an empty -Name raises a parameter-binding - # error that -ErrorAction SilentlyContinue cannot suppress. - if ($ModuleName) { - Remove-Module -Name $ModuleName -ErrorAction SilentlyContinue + + # Remove only the instance this function imported. The removal used to run by name and + # unconditionally, so on the default path -- where ImportModule is $false and nothing + # is imported at all -- it unloaded whatever the caller happened to have loaded under + # that name. Keying on the imported instance also retires the empty -Name guard that + # used to stand here, because there is no name-based removal left to protect. + if ($importedModule) { + Remove-Module -ModuleInfo $importedModule -Force -ErrorAction SilentlyContinue + } + + # Restored into the global session state, because that is where a caller's copy lives. + # An import issued from inside this module without -Global would only reach + # PowerShellBuild's own session state, and the caller would still see nothing. + foreach ($restoredModule in $previouslyLoadedModule) { + Import-Module -ModuleInfo $restoredModule -Global -ErrorAction SilentlyContinue } } } diff --git a/tests/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 index 738fc9c..7cc5992 100644 --- a/tests/Build-PSBuildHelp.tests.ps1 +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -139,6 +139,105 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { } } + Context 'Build-PSBuildMarkdown and the modules the caller had loaded' { + + # psake/PowerShellBuild#221. The function imported the module it was documenting and + # then, in its finally block, called Remove-Module by name -- which removes every + # loaded module with that name, including a copy the caller loaded and this function + # never imported. Generating documentation therefore emptied the session it ran in. + # PowerShellOrg/PSDepend gave up the docs tasks entirely rather than live with it. + # + # These assert on the session rather than on the generated files, because the files + # were always correct; the session was the casualty. + + It 'leaves a module the caller loaded from the documented path loaded and callable' { + # The blunt case: the caller and the docs build point at the same module, so + # Import-Module -Force displaces the caller's instance on the way in. Restoring it + # is the half of the fix that scoping the removal alone does not cover. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionsamepath' + $manifestPath = Join-Path -Path $scenario.ModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + ProbeModuleManifest = $manifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.ProbeResultBefore.Name | Should -Be 'Sprocket' + + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves the caller''s module alone when the documented module exports nothing' { + # The zero-export path warns and returns without generating a single file, and + # `return` inside `try` still runs the `finally`. So the one case that produces no + # documentation at all used to be just as destructive as the case that does. + # The caller's copy is loaded from a different path here, which is what the + # name-based removal reached and a scoped removal does not. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionnoexport' + $documentedManifestPath = Join-Path -Path $scenario.ModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + (Get-Content -Path $documentedManifestPath -Raw) -replace + "(?s)FunctionsToExport = @\(.*?\)", 'FunctionsToExport = @()' | + Set-Content -Path $documentedManifestPath + + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionnoexportcaller' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath ( + '{0}.psd1' -f $scenario.ModuleName + ) + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.Warning -join ' ' | Should -Match 'no commands' + $scenario.LocalePath | Should -Not -Exist + + $probe.LoadedBefore | Should -Be 1 + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves nothing loaded when the caller had nothing loaded' { + # The other side of restoring: putting back only what was there, so a session that + # started clean does not end up holding the module the docs were generated from. + $scenario = New-PSBuildDocsScenario -Path $TestDrive -Name 'evictionclean' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Build-PSBuildMarkdown' + Parameter = New-PSBuildMarkdownParameter -Scenario $scenario + ProbeModuleName = $scenario.ModuleName + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 0 + $probe.LoadedAfter | Should -Be 0 + } + } + Context 'Build-PSBuildMAMLHelp' { BeforeAll { diff --git a/tests/Test-PSBuildPester.tests.ps1 b/tests/Test-PSBuildPester.tests.ps1 index 43ccb4f..3e30f46 100644 --- a/tests/Test-PSBuildPester.tests.ps1 +++ b/tests/Test-PSBuildPester.tests.ps1 @@ -302,6 +302,103 @@ Describe 'Coverage target' { $result.ErrorMessage | Should -BeNullOrEmpty } + It 'leaves a module the caller had loaded alone when ImportModule is not passed' { + # Regression: psake/PowerShellBuild#222. The import was conditional on + # -ImportModule; the Remove-Module in the finally block was not. ImportModule + # defaults to $false, so on the default Pester task the function imported nothing + # and then removed whatever the caller happened to have loaded under that name. + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionnoimport' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.ProbeResultBefore.Name | Should -Be 'Sprocket' + + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'restores the caller''s module and removes its own after ImportModule' { + # The caller's copy and the copy under test are deliberately different paths, so + # the count distinguishes three outcomes: 0 means the caller was evicted, 2 means + # the function left its own import behind, 1 means it cleaned up exactly what it + # created and put the caller's session back. + $callerModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictioncaller' + ) + $callerManifestPath = Join-Path -Path $callerModulePath -ChildPath 'PSBuildTestFixture.psd1' + $testedModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictiontested' + ) + $testedManifestPath = Join-Path -Path $testedModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + ModuleManifest = $testedManifestPath + ImportModule = $true + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + ProbeModuleManifest = $callerManifestPath + ProbeCommandName = 'Get-Widget' + ProbeCommandParameter = @{ Name = 'Sprocket' } + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 1 + $probe.LoadedAfter | Should -Be 1 + $probe.ProbeResultAfter.Name | Should -Be 'Sprocket' + } + + It 'leaves nothing loaded when the caller had nothing loaded' { + # Restoring must put back only what was there. A session that started without the + # module must not end up holding the copy the test run imported. + $testedModulePath = Copy-PSBuildTestFixture -Destination ( + Join-Path -Path $TestDrive -ChildPath 'evictionclean' + ) + $testedManifestPath = Join-Path -Path $testedModulePath -ChildPath 'PSBuildTestFixture.psd1' + + $probeParameter = @{ + ModulePath = $script:builtModulePath + CommandName = 'Test-PSBuildPester' + Parameter = @{ + Path = $script:healthyPath + ModuleName = 'PSBuildTestFixture' + ModuleManifest = $testedManifestPath + ImportModule = $true + OutputVerbosity = 'None' + } + ProbeModuleName = 'PSBuildTestFixture' + } + $probe = Invoke-PSBuildModuleEvictionProbe @probeParameter + + $probe.Threw | Should -BeFalse + $probe.LoadedBefore | Should -Be 0 + $probe.LoadedAfter | Should -Be 0 + } + It 'honors the Pester version that is already loaded' -Skip:($script:innerPesterVersions.Count -lt 2) { # 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 diff --git a/tests/fixtures/FixtureHelpers.psm1 b/tests/fixtures/FixtureHelpers.psm1 index 75530b2..827af93 100644 --- a/tests/fixtures/FixtureHelpers.psm1 +++ b/tests/fixtures/FixtureHelpers.psm1 @@ -394,9 +394,190 @@ function Invoke-TestPSBuildPesterInJob { Invoke-PSBuildCommandInJob @jobParameter } +function Invoke-PSBuildModuleEvictionProbe { + <# + .SYNOPSIS + Run one PowerShellBuild command in a background job and report what it did to the + caller's loaded modules. + .DESCRIPTION + Loads a module the way a consumer would, invokes a PowerShellBuild command, and reports + what was loaded and callable on both sides of the call. That before-and-after pair is + the whole point: psake/PowerShellBuild#221 and #222 both left the command's own output + correct while emptying the session around it, so only a probe that looks at the session + can see the defect. + + The probe runs in a background job for the same reason Invoke-PSBuildCommandInJob does, + and then some -- an eviction is by definition a change to the session it runs in, so + measuring it in the test session would corrupt the test session. + + Supply no ProbeModuleManifest to measure the opposite case: with nothing loaded + beforehand, LoadedAfter says whether the command left something behind. + .PARAMETER ModulePath + Path to the built PowerShellBuild module to import inside the job. + .PARAMETER CommandName + Name of the PowerShellBuild command to invoke. + .PARAMETER Parameter + Parameters to splat onto the command. + .PARAMETER ProbeModuleName + Name of the module to count before and after the call. + .PARAMETER ProbeModuleManifest + Manifest of the module to import globally before the call, standing in for a module the + consumer had loaded. Omit it to start from a session with nothing loaded. + .PARAMETER ProbeCommandName + Command exported by the probe module to invoke before and after the call. A module can + be reported as loaded while its commands are no longer resolvable, so the count alone + is not proof the session survived. + .PARAMETER ProbeCommandParameter + Parameters to splat onto the probe command. + .PARAMETER TimeoutSecond + How long to wait before giving up. Defaults to 300. + .EXAMPLE + PS> $probe = Invoke-PSBuildModuleEvictionProbe -ModulePath $builtModulePath -CommandName 'Build-PSBuildMarkdown' -Parameter $parameter -ProbeModuleName 'PSBuildTestFixture' -ProbeModuleManifest $manifestPath -ProbeCommandName 'Get-Widget' -ProbeCommandParameter @{ Name = 'Sprocket' } + + Reports whether generating markdown left the caller's PSBuildTestFixture loaded and + Get-Widget callable. + .OUTPUTS + System.Management.Automation.PSCustomObject + #> + # The job scriptblock declares its own param() block and receives values through + # -ArgumentList, which is the documented alternative to $using:. The analyzer does not + # model that pairing and reports every parameter as an undeclared variable. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ModulePath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $CommandName, + + [Parameter(Mandatory)] + [ValidateNotNull()] + [hashtable] + $Parameter, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] + $ProbeModuleName, + + [string] + $ProbeModuleManifest, + + [string] + $ProbeCommandName, + + [ValidateNotNull()] + [hashtable] + $ProbeCommandParameter = @{}, + + [ValidateRange(1, 3600)] + [int] + $TimeoutSecond = 300 + ) + + $job = Start-Job -ScriptBlock { + param( + $modulePath, + $commandName, + $parameter, + $probeModuleName, + $probeModuleManifest, + $probeCommandName, + $probeCommandParameter + ) + + Import-Module -Name $modulePath -Force -ErrorAction Stop + + # Imported globally because that is how a consumer's session holds a module, and the + # global session state is the one the defect empties. + if ($probeModuleManifest) { + Import-Module -Name $probeModuleManifest -Force -Global -ErrorAction Stop + } + + # A failed probe reports as $null rather than an error, so a test asserts on the value + # it expected back instead of on an exception whose text says nothing about the module. + $probeScript = { + param($name, $probeParameter) + + if (-not $name) { + return $null + } + try { + & $name @probeParameter + } catch { + $null + } + } + + $loadedBefore = @(Get-Module -Name $probeModuleName).Count + $probeResultBefore = & $probeScript $probeCommandName $probeCommandParameter + + $invokeParameter = @{} + foreach ($key in $parameter.Keys) { + $invokeParameter[$key] = $parameter[$key] + } + if (-not $invokeParameter.ContainsKey('ErrorAction')) { + $invokeParameter['ErrorAction'] = 'Stop' + } + $commandWarning = @() + if (-not $invokeParameter.ContainsKey('WarningVariable')) { + $invokeParameter['WarningVariable'] = 'commandWarning' + } + + $threw = $false + $errorMessage = $null + try { + $null = & $commandName @invokeParameter + } catch { + $threw = $true + $errorMessage = $_.Exception.Message + } + + $loadedAfter = @(Get-Module -Name $probeModuleName).Count + $probeResultAfter = & $probeScript $probeCommandName $probeCommandParameter + + [PSCustomObject]@{ + Threw = $threw + ErrorMessage = $errorMessage + Warning = @($commandWarning.ForEach({ $_.ToString() })) + LoadedBefore = $loadedBefore + LoadedAfter = $loadedAfter + ProbeResultBefore = $probeResultBefore + ProbeResultAfter = $probeResultAfter + } + } -ArgumentList $ModulePath, $CommandName, $Parameter, $ProbeModuleName, $ProbeModuleManifest, $ProbeCommandName, $ProbeCommandParameter + + $completedJob = Wait-Job -Job $job -Timeout $TimeoutSecond + if (-not $completedJob) { + Stop-Job -Job $job + Remove-Job -Job $job -Force + return [PSCustomObject]@{ + Threw = $true + ErrorMessage = "$CommandName did not complete within $TimeoutSecond seconds." + Warning = @() + LoadedBefore = -1 + LoadedAfter = -1 + ProbeResultBefore = $null + ProbeResultAfter = $null + } + } + + $jobResult = Receive-Job -Job $job + Remove-Job -Job $job -Force + $jobResult +} + + Export-ModuleMember -Function @( 'Copy-PSBuildTestFixture' 'Invoke-PSBuildCommandInJob' + 'Invoke-PSBuildModuleEvictionProbe' 'Invoke-TestPSBuildPesterInJob' 'New-PSBuildDocsScenario' 'New-PSBuildMarkdownParameter' From c36cc446cca23e38ec3752055b18112c4ab3e3c6 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:08:57 -0400 Subject: [PATCH 2/2] docs: Record the module eviction fixes for upgrading consumers Both fixes change observable behavior on upgrade: a build that used to silently unload a consumer's module stops doing so. The migration guide gains an entry per task, including how to tell whether you were affected and how to put back documentation tasks that were dropped to avoid it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- CHANGELOG.md | 21 +++++++++ docs/migration-v0.8-to-v1.0.md | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ceed4..d59aebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,27 @@ Everything below is the detail, one entry per issue. ### Fixed +- [**#221**](https://github.com/psake/PowerShellBuild/issues/221), + [**#222**](https://github.com/psake/PowerShellBuild/issues/222) + A build no longer unloads modules from the session it runs in. + `Build-PSBuildMarkdown` and `Test-PSBuildPester` both ended with + `Remove-Module -Name `, which removes *every* loaded module of that + name — including a copy you loaded yourself and neither function ever imported. + Both are on default paths: `Build` depends on `BuildHelp` depends on + `GenerateMarkdown`, and `Test` depends on `Pester`. `Test-PSBuildPester` was the + worse of the two, because its import is conditional on `-ImportModule` (which + defaults to `$false`) while its removal was not — so on the default `Test` chain + it removed a module it had never touched. The symptom was a command that worked a + moment ago no longer being recognized, with nothing in the build output to explain + it; `PowerShellOrg/PSDepend` gave up generated documentation entirely rather than + live with it. Both functions now record what was loaded before they import, + remove only the instance they created, and restore what they displaced — + including on the zero-export path, where `Build-PSBuildMarkdown` warns and + returns without generating anything. `Build-PSBuildMarkdown` also no longer + imports with `-Global`: PlatyPS resolves the module through the `PSModuleInfo` + object rather than by name, so the import no longer reaches into your session + state at all. The generated markdown is unchanged. + - [**#206**](https://github.com/psake/PowerShellBuild/issues/206) `Build-PSBuildModule -Compile` no longer compiles your working directory when no compile directories are given. `CompileDirectories` defaulted to `@()`, and diff --git a/docs/migration-v0.8-to-v1.0.md b/docs/migration-v0.8-to-v1.0.md index af19ce3..10b7708 100644 --- a/docs/migration-v0.8-to-v1.0.md +++ b/docs/migration-v0.8-to-v1.0.md @@ -63,6 +63,12 @@ One line per break; follow the link for details and migration steps. - [`$PSBPreference.Sign.SkipCertificateValidation` now has an effect](#psbpreferencesignskipcertificatevalidation-now-has-an-effect) — the escape hatch did nothing on 0.8.x; a build that failed on an expired certificate may now succeed by signing with it. +- [The `GenerateMarkdown` task no longer unloads your module](#the-generatemarkdown-task-no-longer-unloads-your-module) + — building help emptied the session it ran in; if you dropped the docs + tasks to avoid that, you can put them back. +- [The `Pester` task no longer unloads a module it never imported](#the-pester-task-no-longer-unloads-a-module-it-never-imported) + — the same defect on the default `Test` chain, where nothing was imported + to justify the removal at all. ## AI-assisted migration @@ -1012,6 +1018,84 @@ Note the two source families differ, and the difference matters: Tracked in [#193](https://github.com/psake/PowerShellBuild/issues/193). +### The `GenerateMarkdown` task no longer unloads your module + +On 0.8.x, `Build-PSBuildMarkdown` ended with +`Remove-Module -Name ` in its `finally` block. `Remove-Module +-Name` removes **every** loaded module with that name — including a copy +you loaded yourself, from a different path, that the function never +imported. Generating documentation emptied the session it ran in. + +This was on by default. `Build` depends on `BuildHelp`, which depends on +`GenerateMarkdown`, so every consumer who did not override +`$PSBBuildDependency` ran it on every build. + +On 1.0.0 the function records the copies you had loaded, removes only the +instance it imported itself, and re-imports what it displaced — including +on the zero-export path, where it warns +`No commands have been exported. Skipping markdown generation.` and +returns without generating anything. + +**Detection.** You were affected if a command that worked before your build +stopped being recognized afterwards, in a session where nothing obviously +removed it: + + The term 'Get-Widget' is not recognized as a name of a cmdlet, function, + script file, or executable program. + +**If you worked around this by dropping the documentation tasks**, you can +put them back. `PowerShellOrg/PSDepend` did exactly that: + +**Before (0.8.x):** + +```powershell +# Skips BuildHelp (GenerateMarkdown) - Build-PSBuildMarkdown has a Remove-Module scope bug +$PSBBuildDependency = @('StageFiles') +``` + +**After (1.0.0):** + +```powershell +# The default is fine again; delete the override entirely +``` + +Two smaller notes. The restored module is a fresh import rather than +literally your original instance, so a `PSModuleInfo` reference you were +holding across the call goes stale — a much smaller problem than the +command disappearing, and the only way to keep `-Force` refreshing the +documented command surface. And the import no longer passes `-Global`: +PlatyPS resolves the module through the `PSModuleInfo` object it is handed, +never by name, so the import no longer reaches into your session state at +all. The generated markdown is byte-for-byte unchanged. + +Tracked in [#221](https://github.com/psake/PowerShellBuild/issues/221). + +### The `Pester` task no longer unloads a module it never imported + +The same defect in a worse form. On 0.8.x, `Test-PSBuildPester` imported +the module under test only when `-ImportModule` was passed, but removed it +by name **unconditionally**. `$PSBPreference.Test.ImportModule` defaults to +`$false`, and both task files forward it verbatim, so for every consumer who +had not turned it on, the `Pester` task imported nothing and then removed +whatever you happened to have loaded under that name. `Test` depends on +`Pester`, so `./build.ps1` and `./build.ps1 -Task Test` both did it. + +On 1.0.0 the function removes only the instance it imported, and restores +anything it displaced on the way in. When `-ImportModule` is not passed it +now touches your loaded modules not at all. + +**Detection** is the same as the entry above: a command that was available +before the build is not available after it. This one reaches more builds, +because a consumer who turned documentation generation off still runs tests. + +No build-file change is needed. If you set +`$PSBPreference.Test.ImportModule = $true` purely to make the removal +symmetrical, that is no longer a reason to keep it — but leaving it on is +harmless, and the module under test is still imported from the output +directory, still displacing a stale copy for the duration of the run. + +Tracked in [#222](https://github.com/psake/PowerShellBuild/issues/222). + ## Adding an entry (for PR contributors) Every breaking-change PR that lands in v1.0.0 must add an entry here for