From 3fd5850a32e47869f4e782621b40389fb9733ad6 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:06:10 -0400 Subject: [PATCH 1/3] test: Give the test fixture a culture directory PSBuildTestFixture had no culture directory, no .psd1 below its root and no about topic, so the staging tests were structurally incapable of observing psake/PowerShellBuild#210, #211 or #212. All three have been reachable since 2018 with the suite green. Adds en-US/Messages.psd1 and a hand-written en-US/about_PSBuildTestFixture.help.txt and asserts both in the fixture layout test. Narrows one assertion in Build-PSBuildHelp.tests.ps1 as a consequence. Microsoft.PowerShell.PlatyPS 1.x warns "File '' is not a valid help file type" for every file in the module's locale directory that is not the generated MAML, a hand-written about topic included, and with an empty name in its own message. That is upstream behavior about what the module ships, not the docs-tree defect the context pins, so it now asserts that no landing-page warning is reported rather than that no warning at all is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- tests/Build-PSBuildHelp.tests.ps1 | 10 ++++++++- tests/Fixtures.tests.ps1 | 9 ++++++++ .../PSBuildTestFixture/en-US/Messages.psd1 | 11 ++++++++++ .../en-US/about_PSBuildTestFixture.help.txt | 21 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/PSBuildTestFixture/en-US/Messages.psd1 create mode 100644 tests/fixtures/PSBuildTestFixture/en-US/about_PSBuildTestFixture.help.txt diff --git a/tests/Build-PSBuildHelp.tests.ps1 b/tests/Build-PSBuildHelp.tests.ps1 index 738fc9c..8aac2e1 100644 --- a/tests/Build-PSBuildHelp.tests.ps1 +++ b/tests/Build-PSBuildHelp.tests.ps1 @@ -372,7 +372,15 @@ Describe 'Help building functions' -Skip:(-not $script:platyPSAvailable) { # The whole of the defect: one warning per non-locale directory, naming a landing # page the consumer was never going to write. - $script:strayCabResult.Warning -join ' ' | Should -BeNullOrEmpty + # + # Narrowed from "no warnings at all" when the fixture gained a culture directory. + # Microsoft.PowerShell.PlatyPS 1.x warns "File '' is not a valid help file type" + # for every file in the module's locale directory that is not the generated MAML -- + # a hand-written about topic included, and with an empty name in its own message. + # That is upstream behavior about what the module ships, and every module with a + # culture directory gets it; it says nothing about the docs tree this context is + # about. + $script:strayCabResult.Warning -join ' ' | Should -Not -Match 'landing page' Get-ChildItem -Path $script:strayScenario.UpdatableHelpPath -Filter '*.cab' | Should -Not -BeNullOrEmpty diff --git a/tests/Fixtures.tests.ps1 b/tests/Fixtures.tests.ps1 index 84fbec6..98c03f3 100644 --- a/tests/Fixtures.tests.ps1 +++ b/tests/Fixtures.tests.ps1 @@ -26,6 +26,15 @@ Describe 'PSBuildTestFixture' { Join-Path -Path $fixturePath -ChildPath 'Public/Set-Widget.ps1' | Should -Exist Join-Path -Path $fixturePath -ChildPath 'Private/Test-WidgetName.ps1' | Should -Exist Join-Path -Path $fixturePath -ChildPath 'excludeme.txt' | Should -Exist + + # The culture directory is what psake/PowerShellBuild#210, #211 and #212 are all + # about, and the fixture had none until those were fixed -- which is why all three + # survived since 2018 with the build tests passing. Both files earn their place: + # the about topic is the file compile mode used to drop, and Messages.psd1 is the + # file the staging glob used to flatten into the output root. + Join-Path -Path $fixturePath -ChildPath 'en-US/about_PSBuildTestFixture.help.txt' | + Should -Exist + Join-Path -Path $fixturePath -ChildPath 'en-US/Messages.psd1' | Should -Exist } It 'Recopying into the same destination replaces the copy without nesting' { diff --git a/tests/fixtures/PSBuildTestFixture/en-US/Messages.psd1 b/tests/fixtures/PSBuildTestFixture/en-US/Messages.psd1 new file mode 100644 index 0000000..3df912b --- /dev/null +++ b/tests/fixtures/PSBuildTestFixture/en-US/Messages.psd1 @@ -0,0 +1,11 @@ +# Localized data for the fixture module. +# +# The fixture carries a culture directory because staging one is where +# psake/PowerShellBuild#210, #211 and #212 all live, and a fixture without one made every +# one of them invisible to the suite. This file is the "localized data" half of that: it is +# what used to be flattened into the output root by the depth-1 staging glob (#211), and it +# is a culture directory whose content is not an about topic, which is what keeps the +# content test in Build-PSBuildModule honest. +ConvertFrom-StringData @' +WidgetNotFound=No widget named [{0}] was found. +'@ diff --git a/tests/fixtures/PSBuildTestFixture/en-US/about_PSBuildTestFixture.help.txt b/tests/fixtures/PSBuildTestFixture/en-US/about_PSBuildTestFixture.help.txt new file mode 100644 index 0000000..98765f0 --- /dev/null +++ b/tests/fixtures/PSBuildTestFixture/en-US/about_PSBuildTestFixture.help.txt @@ -0,0 +1,21 @@ +TOPIC + about_PSBuildTestFixture + +SHORT DESCRIPTION + Describes the hand-written about topic the PSBuildTestFixture module ships. + +LONG DESCRIPTION + This is a hand-written about topic, written in the structure Get-Help documents: + a TOPIC line, a SHORT DESCRIPTION, and four-space-indented body text. + + It exists so the test suite can tell a conformant about topic apart from a Markdown + readme copied over the top of one. A build that converts the readme into about help + must leave this file alone and say so, in compile and non-compile mode alike. See + psake/PowerShellBuild#210 and psake/PowerShellBuild#212. + +KEYWORDS + PSBuildTestFixture + +SEE ALSO + Get-Widget + Set-Widget From 18c9be0c9f5cd48004bd1b2374e8e59a106fccf5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:06:24 -0400 Subject: [PATCH 2/3] fix: Correct three defects in how a module is staged Sequenced #211, #210, #212, because each one is only visible once the one before it is fixed. psake/PowerShellBuild#211: the loose-file glob used -Path $Path with -Include and -Depth 1, so it recursed one level and copied en-US/Messages.psd1 flat into the output root, where nothing reads it. Both halves of the fix are required: the trailing wildcard alone still matches the culture directory, and dropping -Depth alone matches nothing at all, because without recursion -Include filters against the leaf of -Path. On Windows PowerShell 5.1, -Depth with -Include degrades to a full -Recurse, so files at any depth were flattened into the root and the contents of a package depended on which host built it. The same pattern in the readme discovery in psakeFile.ps1 and IB.tasks.ps1 is fixed with it. psake/PowerShellBuild#210: compile mode staged the loose root files and CopyDirectories and nothing else, so a hand-written about topic never reached the output unless CopyDirectories happened to name the culture directory. Compile mode now stages a source culture directory on its own. Get-PSBuildHelpLocale answers which directories are cultures, but deliberately over-reports -- 'bin' is Bini and 'ps' is Pashto -- so staging is decided by content: an about topic, MAML help, or localized data. psake/PowerShellBuild#212: the two modes disagreed about whether a source about topic or the readme won, by accident of statement ordering. A source about topic now wins in both, with a warning, because ConvertReadMeToAboutHelp performs no conversion -- it is a plain copy of the Markdown, which is not a conformant about topic. The guard tests the source about *file*, which is narrower than the culture *directory* guard #207 removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- PowerShellBuild/IB.tasks.ps1 | 13 +- .../Private/Get-PSBuildHelpLocale.ps1 | 9 +- .../Public/Build-PSBuildModule.ps1 | 117 ++++++++-- PowerShellBuild/en-US/Messages.psd1 | 1 + PowerShellBuild/psakeFile.ps1 | 13 +- tests/Build-PSBuildModule.tests.ps1 | 218 +++++++++++++++++- 6 files changed, 347 insertions(+), 24 deletions(-) diff --git a/PowerShellBuild/IB.tasks.ps1 b/PowerShellBuild/IB.tasks.ps1 index e299ffa..7e8011f 100644 --- a/PowerShellBuild/IB.tasks.ps1 +++ b/PowerShellBuild/IB.tasks.ps1 @@ -26,8 +26,17 @@ Task StageFiles Clean, { } if ($PSBPreference.Help.ConvertReadMeToAboutHelp) { - $readMePath = Get-ChildItem -Path $PSBPreference.General.ProjectRoot -Include 'readme.md', 'readme.markdown', 'readme.txt' -Depth 1 | - Select-Object -First 1 + # The project root only, and only the trailing wildcard reaches it. -Depth 1 combined + # with -Include degrades to a full -Recurse on Windows PowerShell 5.1, so this used to + # walk the entire project root -- including the build output -- and Select-Object then + # took whichever readme the enumeration happened to reach first. Removing -Depth without + # adding the wildcard matches nothing, because without recursion -Include filters against + # the leaf of -Path. See psake/PowerShellBuild#211. + $getReadMeSplat = @{ + Path = [IO.Path]::Combine($PSBPreference.General.ProjectRoot, '*') + Include = 'readme.md', 'readme.markdown', 'readme.txt' + } + $readMePath = Get-ChildItem @getReadMeSplat | Select-Object -First 1 if ($readMePath) { $buildParams.ReadMePath = $readMePath } diff --git a/PowerShellBuild/Private/Get-PSBuildHelpLocale.ps1 b/PowerShellBuild/Private/Get-PSBuildHelpLocale.ps1 index 52a15de..699a37b 100644 --- a/PowerShellBuild/Private/Get-PSBuildHelpLocale.ps1 +++ b/PowerShellBuild/Private/Get-PSBuildHelpLocale.ps1 @@ -1,3 +1,4 @@ +# spell-checker:ignore Bini Pashto function Get-PSBuildHelpLocale { <# .SYNOPSIS @@ -23,8 +24,14 @@ function Get-PSBuildHelpLocale { codes is reported as a locale even if it holds no help at all. That is the safe way to be wrong. Callers already handle a locale that turns out to have nothing to build, and over-reporting costs a warning where under-reporting silently drops a consumer's help. + + A caller that acts on the result by copying rather than by reporting has to narrow it + further, because there the same over-report is not safe: 'bin' is a real culture name + (Bini) and 'ps' is Pashto, so staging by name alone would copy a binary directory into a + built module. Build-PSBuildModule adds a content test for that reason. .PARAMETER Path - Path to the docs tree whose subdirectories are being classified. + Path to the tree whose subdirectories are being classified. A docs tree in the help + tasks, and a module source directory when staging a build. .PARAMETER ModulePath Path to the built module. Its subdirectories are the locales the MAML step has already written help for. Optional; without it only the culture name test applies. diff --git a/PowerShellBuild/Public/Build-PSBuildModule.ps1 b/PowerShellBuild/Public/Build-PSBuildModule.ps1 index 249d367..e6ad383 100644 --- a/PowerShellBuild/Public/Build-PSBuildModule.ps1 +++ b/PowerShellBuild/Public/Build-PSBuildModule.ps1 @@ -1,4 +1,4 @@ -# spell-checker:ignore modulename +# spell-checker:ignore modulename Bini Pashto function Build-PSBuildModule { <# .SYNOPSIS @@ -26,7 +26,10 @@ function Build-PSBuildModule { String that will be added to your PSM1 file after each script file. .PARAMETER ReadMePath Path to project README. If present, this will become the - "about_.help.txt" file in the build module. + "about_.help.txt" file in the build module. A hand-written + about topic in the source tree's culture directory takes precedence over + it, in both compile and non-compile mode, and a warning reports that the + readme was not used. .PARAMETER CompileDirectories List of directories containing .ps1 files that will also be compiled into the PSM1. @@ -98,11 +101,21 @@ function Build-PSBuildModule { New-Item @newItemSplat > $null } - # Copy "non-processed files" + # Copy "non-processed files". This stages the module's loose root files -- the manifest, the + # root module, and any format or type data -- and nothing else. Anything below the root is + # CopyDirectories' job, or the culture staging below. + # + # Both halves of this splat matter, and either one on its own is wrong. -Depth 1 implied + # recursion one level down, so a localized en-US/Messages.psd1 matched and Copy-Item wrote it + # flat into the output root, where nothing reads it (psake/PowerShellBuild#211). Windows + # PowerShell 5.1 makes that worse: -Depth combined with -Include degrades to a full -Recurse + # there, so files at any depth were flattened into the root and same-named files at different + # depths collided. Removing -Depth alone matches nothing at all, because without recursion + # -Include filters against the leaf of -Path rather than against that directory's children -- + # so the trailing wildcard has to be added at the same time. $getChildItemSplat = @{ - Path = $Path + Path = [IO.Path]::Combine($Path, '*') Include = '*.psm1', '*.psd1', '*.ps1xml' - Depth = 1 } Get-ChildItem @getChildItemSplat | Copy-Item -Destination $DestinationPath -Force @@ -111,6 +124,45 @@ function Build-PSBuildModule { Copy-Item -Path $copyPath -Destination $DestinationPath -Recurse -Force } + # A module's culture directory carries its localized data and its about topics. Compile mode + # stages the loose root files and CopyDirectories and nothing else, so a hand-written + # en-US/about_.help.txt was left behind and the built module shipped without its about + # topic while the build reported success. Naming the culture directory in CopyDirectories was + # the only way to ship one, and that setting reads as an escape hatch for extra content rather + # than as the mechanism help travels by. See psake/PowerShellBuild#210. + # + # Non-compile mode needs none of this: the bulk copy below already brings the whole source + # tree, culture directories included. + if ($Compile.IsPresent) { + foreach ($localeName in (Get-PSBuildHelpLocale -Path $Path)) { + # Already staged verbatim by the loop above. + if ($localeName -in $CopyDirectories) { + continue + } + + # Get-PSBuildHelpLocale deliberately over-reports: a directory counts as a locale when + # its name is a culture the runtime knows, whether or not it holds any help. That is + # the safe way to be wrong where the cost is a warning, and the unsafe way here -- + # 'bin' is a real culture name (Bini) and 'ps' is Pashto, so staging on the name alone + # would copy a binary directory into the shipped module. Content is what decides: an + # about topic, MAML help, or localized data is what makes a directory a culture + # directory rather than a directory that happens to share a name with one. + $localePath = [IO.Path]::Combine($Path, $localeName) + $getChildItemSplat = @{ + Path = [IO.Path]::Combine($localePath, '*') + Include = 'about_*.help.txt', '*-help.xml', '*.psd1' + File = $true + ErrorAction = 'SilentlyContinue' + } + $localeContent = Get-ChildItem @getChildItemSplat | Select-Object -First 1 + if (-not $localeContent) { + continue + } + + Copy-Item -Path $localePath -Destination $DestinationPath -Recurse -Force + } + } + # Copy README as about_.help.txt if (-not [string]::IsNullOrEmpty($ReadMePath)) { $culturePath = [IO.Path]::Combine($DestinationPath, $Culture) @@ -118,21 +170,50 @@ function Build-PSBuildModule { $culturePath, "about_$($ModuleName).help.txt" ) - # The guard belongs to New-Item alone. With the copy inside it, an existing - # culture directory meant no about help file was written at all -- and - # CopyDirectories runs above, so naming the culture directory there was enough - # to suppress it silently. That is psake/PowerShellBuild#207. The Force below was - # already here and unreachable; this restores the overwrite it was written for. - if (-not (Test-Path -LiteralPath $culturePath -PathType Container)) { - New-Item -Path $culturePath -ItemType Directory -Force > $null - } - $copyItemSplat = @{ - LiteralPath = $ReadMePath - Destination = $aboutModulePath - Force = $true + # A hand-written about topic in the source tree wins over the readme, in both modes. + # The two modes used to disagree by accident of statement ordering: non-compile mode's + # bulk copy runs after this block and overwrote the readme-derived file, while in compile + # mode the readme landed last and replaced whatever CopyDirectories had staged. Same two + # inputs, opposite results, decided by a setting that has nothing to do with help. See + # psake/PowerShellBuild#212. + # + # Source wins because no conversion happens here: this is a plain copy of the Markdown + # readme, which satisfies none of the TOPIC and four-space-indent structure Get-Help + # documents for an about topic. Letting the readme win would replace a conformant help + # file with raw Markdown. Warned about rather than done silently, because + # ConvertReadMeToAboutHelp is an explicit instruction that is not being carried out. + # + # Tested against the source tree, not the output: non-compile mode has not copied the + # source about topic yet at this point, so the output cannot answer the question in + # either mode. This is a narrower guard than the one psake/PowerShellBuild#207 removed -- + # that one skipped the copy whenever the culture *directory* existed, whatever was in it. + $sourceAboutModulePath = [IO.Path]::Combine( + $Path, + $Culture, + "about_$($ModuleName).help.txt" + ) + if (Test-Path -LiteralPath $sourceAboutModulePath -PathType Leaf) { + Write-Warning ( + $LocalizedData.SourceAboutTopicOverridesReadMe -f $sourceAboutModulePath, $ReadMePath + ) + } else { + # The guard belongs to New-Item alone. With the copy inside it, an existing + # culture directory meant no about help file was written at all -- and + # CopyDirectories runs above, so naming the culture directory there was enough + # to suppress it silently. That is psake/PowerShellBuild#207. The Force below was + # already here and unreachable; this restores the overwrite it was written for. + if (-not (Test-Path -LiteralPath $culturePath -PathType Container)) { + New-Item -Path $culturePath -ItemType Directory -Force > $null + } + + $copyItemSplat = @{ + LiteralPath = $ReadMePath + Destination = $aboutModulePath + Force = $true + } + Copy-Item @copyItemSplat } - Copy-Item @copyItemSplat } # Copy source files to destination and optionally combine *.ps1 files diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 6b5c93b..7a53d2a 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -43,5 +43,6 @@ CertificateExpired=The resolved certificate has expired (NotAfter: {0}). Code si CertificateMissingCodeSigningEku=The resolved certificate does not have the Code Signing Enhanced Key Usage (EKU: 1.3.6.1.5.5.7.3.3). Subject=[{0}] CertificateSourceStoreNotSupported=CertificateSource 'Store' is only supported on Windows platforms. CertificateValidationRelaxed=No unexpired code signing certificate was found, and validation was skipped, so an expired certificate was selected (NotAfter: {0}). Subject=[{1}] +SourceAboutTopicOverridesReadMe=The source tree already provides an about help topic at [{0}], so the readme at [{1}] was not converted into about help. The readme is copied as-is rather than converted, and Markdown is not a conformant about topic, so using it would replace hand-written help with content Get-Help cannot present. Remove the source about topic to have the readme used instead, or stop setting ConvertReadMeToAboutHelp. ExportModuleMemberInSourceRootModule=The source root module [{0}] calls Export-ModuleMember. Compiling appends that call after the concatenated function files, where it runs against an output directory that no longer has the function directories to discover, and the effective export set is the intersection of that call and FunctionsToExport in the manifest. A loader that finds nothing therefore leaves the built module exporting nothing. Guard the call so it does nothing when the function directories are absent, or remove it and let the manifest govern the export set. '@ diff --git a/PowerShellBuild/psakeFile.ps1 b/PowerShellBuild/psakeFile.ps1 index daef4ce..67200cd 100644 --- a/PowerShellBuild/psakeFile.ps1 +++ b/PowerShellBuild/psakeFile.ps1 @@ -91,8 +91,17 @@ Task StageFiles -Depends $PSBStageFilesDependency { } if ($PSBPreference.Help.ConvertReadMeToAboutHelp) { - $readMePath = Get-ChildItem -Path $PSBPreference.General.ProjectRoot -Include 'readme.md', 'readme.markdown', 'readme.txt' -Depth 1 | - Select-Object -First 1 + # The project root only, and only the trailing wildcard reaches it. -Depth 1 combined + # with -Include degrades to a full -Recurse on Windows PowerShell 5.1, so this used to + # walk the entire project root -- including the build output -- and Select-Object then + # took whichever readme the enumeration happened to reach first. Removing -Depth without + # adding the wildcard matches nothing, because without recursion -Include filters against + # the leaf of -Path. See psake/PowerShellBuild#211. + $getReadMeSplat = @{ + Path = [IO.Path]::Combine($PSBPreference.General.ProjectRoot, '*') + Include = 'readme.md', 'readme.markdown', 'readme.txt' + } + $readMePath = Get-ChildItem @getReadMeSplat | Select-Object -First 1 if ($readMePath) { $buildParams.ReadMePath = $readMePath } diff --git a/tests/Build-PSBuildModule.tests.ps1 b/tests/Build-PSBuildModule.tests.ps1 index 69d0b3c..84146c1 100644 --- a/tests/Build-PSBuildModule.tests.ps1 +++ b/tests/Build-PSBuildModule.tests.ps1 @@ -20,6 +20,12 @@ # call becomes Export-ModuleMember -Function @() and the built module exports nothing even # though the manifest names every public function. The build still succeeds; the warning # added for #201 is the only thing that says so. +# +# The fixture also carries a culture directory -- en-US/Messages.psd1 and a hand-written +# en-US/about_PSBuildTestFixture.help.txt -- which the staging contexts below read. It had none +# until psake/PowerShellBuild#210, #211 and #212 were fixed, and that absence is why all three +# survived since 2018: every one of them needs a culture directory, a .psd1 below the module +# root, or a source about topic before it can be observed at all. Describe 'Build-PSBuildModule' { @@ -244,6 +250,22 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { Join-Path -Path $script:scenario.DestinationPath -ChildPath 'excludeme.txt' | Should -Not -Exist } + It 'Stages <_> in the culture directory' -ForEach @( + 'about_PSBuildTestFixture.help.txt', + 'Messages.psd1' + ) { + [IO.Path]::Combine($script:scenario.DestinationPath, 'en-US', $_) | Should -Exist + } + + It 'Does not flatten a culture directory file into the output root' { + # The loose-file glob runs in both modes, so the depth-1 recursion that + # psake/PowerShellBuild#211 is about copied en-US/Messages.psd1 flat into the root + # here too, alongside the correct copy the bulk stage brings. Nothing read the stray + # file; it only misled anyone reading the published tree. + Join-Path -Path $script:scenario.DestinationPath -ChildPath 'Messages.psd1' | + Should -Not -Exist + } + It 'Writes the public function names into FunctionsToExport' { $manifest = Import-PowerShellDataFile -Path $script:scenario.ManifestPath @@ -278,6 +300,13 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { $excludedScriptPath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'Private/excludeme.ps1' Set-Content -Path $excludedScriptPath -Value 'function Get-ExcludedWidget { }' + # A directory whose name is a culture the runtime knows -- bin is Bini -- but which + # holds no help of any kind. Culture staging must leave it where it is, or every + # module that ships compiled binaries in bin/ would carry them into its output. + $binaryPath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'bin' + New-Item -Path $binaryPath -ItemType Directory -Force > $null + Set-Content -Path (Join-Path -Path $binaryPath -ChildPath 'widget-tool.txt') -Value 'not help' + $buildParameter = @{ Path = $script:scenario.SourcePath DestinationPath = $script:scenario.DestinationPath @@ -299,11 +328,37 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { } It 'Produces only the manifest and a monolithic root module' { + # The count is also the psake/PowerShellBuild#211 guard: the fixture's + # en-US/Messages.psd1 used to be flattened into the root by the depth-1 glob, which + # made this three. @(Get-ChildItem -Path $script:scenario.DestinationPath -File).Count | Should -Be 2 $script:scenario.ManifestPath | Should -Exist $script:scenario.RootModulePath | Should -Exist } + It 'Does not flatten a culture directory file into the output root' { + Join-Path -Path $script:scenario.DestinationPath -ChildPath 'Messages.psd1' | + Should -Not -Exist + } + + It 'Stages <_> in the culture directory without CopyDirectories naming it' -ForEach @( + 'about_PSBuildTestFixture.help.txt', + 'Messages.psd1' + ) { + # psake/PowerShellBuild#210. Compile mode staged the manifest and the root module and + # left the culture directory behind, so a hand-written about topic reached the output + # only when CopyDirectories happened to name en-US -- and three published modules + # surveyed for that issue ship without their about topic for exactly that reason. + # CopyDirectories here names 'resources' and nothing else, which is the point. + [IO.Path]::Combine($script:scenario.DestinationPath, 'en-US', $_) | Should -Exist + } + + It 'Does not stage a directory that only shares a name with a culture' { + # Staging by culture name alone would copy bin/ (Bini) and ps/ (Pashto) into every + # built module. Content is what makes a directory a culture directory. + Join-Path -Path $script:scenario.DestinationPath -ChildPath 'bin' | Should -Not -Exist + } + It 'Does not copy the <_> compile directory to the output' -ForEach @('Public', 'Private') { Join-Path -Path $script:scenario.DestinationPath -ChildPath $_ | Should -Not -Exist } @@ -693,10 +748,18 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { } } - Context 'Converting the readme into about help' { + Context 'Converting the readme into about help when compiling' { BeforeAll { $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help' + + # The fixture ships a hand-written about topic, and psake/PowerShellBuild#212 gives + # that precedence over the readme. Removed here so this context still covers the + # conversion itself; the precedence is covered by the two contexts below. + Remove-Item -LiteralPath ([IO.Path]::Combine( + $script:scenario.SourcePath, 'en-US', 'about_PSBuildTestFixture.help.txt' + )) -Force + $script:readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' $script:readMeContent = '# PSBuildTestFixture readme content' Set-Content -Path $script:readMePath -Value $script:readMeContent @@ -709,6 +772,57 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { ReadMePath = $script:readMePath Culture = 'en-US' } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + + $script:aboutHelpPath = [IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + ) + } + + It 'Writes the readme as the about help file in the culture directory' { + $script:aboutHelpPath | Should -Exist + } + + It 'Writes the readme content unchanged' { + (Get-Content -Path $script:aboutHelpPath -Raw).Trim() | Should -Be $script:readMeContent + } + + It 'Keeps the rest of the source culture directory' { + # The readme copy writes into the same directory culture staging just created, so a + # module's localized data has to survive alongside its generated about topic. + [IO.Path]::Combine($script:scenario.DestinationPath, 'en-US', 'Messages.psd1') | + Should -Exist + } + + It 'Emits no warning' { + $script:buildWarning | Should -BeNullOrEmpty + } + } + + Context 'Converting the readme into about help without compiling' { + + # The counterpart of the context above. Both modes can produce the about topic, and + # psake/PowerShellBuild#212 is about them agreeing on the result; that is only meaningful + # if the readme conversion is pinned on both sides. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-dot-sourced' + Remove-Item -LiteralPath ([IO.Path]::Combine( + $script:scenario.SourcePath, 'en-US', 'about_PSBuildTestFixture.help.txt' + )) -Force + + $script:readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + $script:readMeContent = '# PSBuildTestFixture readme content' + Set-Content -Path $script:readMePath -Value $script:readMeContent + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + ReadMePath = $script:readMePath + Culture = 'en-US' + } Build-PSBuildModule @buildParameter $script:aboutHelpPath = [IO.Path]::Combine( @@ -721,10 +835,92 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { } It 'Writes the readme content unchanged' { + # The bulk copy runs after the readme block in this mode, so a source tree with no + # about topic of its own must not undo the conversion. (Get-Content -Path $script:aboutHelpPath -Raw).Trim() | Should -Be $script:readMeContent } } + Context 'Preferring a source about topic over the readme when compiling' { + + # psake/PowerShellBuild#212. The two modes used to disagree by accident of statement + # ordering: the non-compile bulk copy runs after the readme block and overwrote the + # readme-derived file, while in compile mode the readme landed last and replaced whatever + # CopyDirectories had staged -- so the winner depended on CompileModule, a setting with + # nothing to do with help. Source wins now in both modes, because nothing here converts + # anything: the readme is copied as-is, and Markdown is not a conformant about topic. + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-source-compiled' + $readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + Set-Content -Path $readMePath -Value '# PSBuildTestFixture readme content' + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + Compile = $true + ReadMePath = $readMePath + Culture = 'en-US' + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + + $script:aboutHelpContent = Get-Content -LiteralPath ([IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + )) -Raw + } + + It 'Ships the hand-written about topic' { + $script:aboutHelpContent | Should -Match 'hand-written about topic' + } + + It 'Does not write the readme over it' { + $script:aboutHelpContent | Should -Not -Match 'PSBuildTestFixture readme content' + } + + It 'Warns that the readme was not converted' { + $script:buildWarning -join [Environment]::NewLine | + Should -BeLike '*about_PSBuildTestFixture.help.txt*' + } + } + + Context 'Preferring a source about topic over the readme without compiling' { + + BeforeAll { + $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-source-dot-sourced' + $readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' + Set-Content -Path $readMePath -Value '# PSBuildTestFixture readme content' + + $buildParameter = @{ + Path = $script:scenario.SourcePath + DestinationPath = $script:scenario.DestinationPath + ModuleName = $script:scenario.ModuleName + ReadMePath = $readMePath + Culture = 'en-US' + } + $script:buildWarning = @() + Build-PSBuildModule @buildParameter -WarningVariable 'buildWarning' -WarningAction 'SilentlyContinue' + $script:buildWarning = @($buildWarning) + + $script:aboutHelpContent = Get-Content -LiteralPath ([IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + )) -Raw + } + + It 'Ships the hand-written about topic' { + $script:aboutHelpContent | Should -Match 'hand-written about topic' + } + + It 'Warns that the readme was not converted' { + # The outcome was already this in non-compile mode, but silently -- the readme was + # written and then overwritten by the bulk copy. The warning is what tells a consumer + # that ConvertReadMeToAboutHelp did not do what they asked. + $script:buildWarning -join [Environment]::NewLine | + Should -BeLike '*about_PSBuildTestFixture.help.txt*' + } + } + Context 'Choosing the about help culture' { BeforeAll { @@ -744,10 +940,21 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { } It 'Writes the about help file into the requested culture directory' { + # The fixture's hand-written about topic is en-US, and the precedence rule is per + # culture: a source topic in one culture says nothing about another, so the readme + # is still what fr-FR gets. [IO.Path]::Combine( $script:scenario.DestinationPath, 'fr-FR', 'about_PSBuildTestFixture.help.txt' ) | Should -Exist } + + It 'Leaves the hand-written topic in its own culture directory alone' { + $aboutHelpPath = [IO.Path]::Combine( + $script:scenario.DestinationPath, 'en-US', 'about_PSBuildTestFixture.help.txt' + ) + + Get-Content -LiteralPath $aboutHelpPath -Raw | Should -Match 'hand-written about topic' + } } Context 'Converting the readme when the culture directory already exists' { @@ -758,8 +965,17 @@ foreach ($sourceDirectoryName in @('Public', 'Private')) { # whenever CopyDirectories names the culture directory -- which, in compile mode, is # the only way to ship a locale directory at all. Fixed in # psake/PowerShellBuild#207; this context asserted Should -Not -Exist beforehand. + # + # The guard psake/PowerShellBuild#212 adds is strictly narrower than the one #207 removed: + # it asks whether the source tree holds an about *file* for this culture, not whether the + # destination culture *directory* exists. The empty directory below is exactly the case + # #207 is about, so the fixture's own about topic is removed to keep the two apart. BeforeAll { $script:scenario = New-PSBuildModuleScenario -Path $TestDrive -Name 'about-help-existing' + Remove-Item -LiteralPath ([IO.Path]::Combine( + $script:scenario.SourcePath, 'en-US', 'about_PSBuildTestFixture.help.txt' + )) -Force + $readMePath = Join-Path -Path $script:scenario.SourcePath -ChildPath 'README.md' Set-Content -Path $readMePath -Value '# PSBuildTestFixture readme content' From f97dac2c42588726a209826ed49c7fbb4fd24c47 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:06:31 -0400 Subject: [PATCH 3/3] docs: Record the staging fixes in the changelog and migration guide One changelog entry per issue, and migration entries for the two changes a consumer can see on upgrade: the output root loses a stray copy of a culture directory's .psd1 and the readme now has to be at the project root to be found (#211), and a hand-written about topic now wins over ConvertReadMeToAboutHelp with a warning (#212). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- CHANGELOG.md | 38 ++++++++++++++ docs/migration-v0.8-to-v1.0.md | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ceed4..3132b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,44 @@ Everything below is the detail, one entry per issue. ### Fixed +- [**#210**](https://github.com/psake/PowerShellBuild/issues/210) + Compiling a module no longer drops its culture directory. `-Compile` staged the + manifest, the root module and `$PSBPreference.Build.CopyDirectories`, and nothing + else — so a hand-written `en-US/about_.help.txt` was left in the source + tree and the built module shipped without its about topic, while the build reported + success. `CopyDirectories` naming the culture directory was the only way to ship one, + and that setting reads as an escape hatch for extra content rather than as the + mechanism help travels by. Compile mode now stages a source culture directory on its + own, so `Get-Help about_` works in a compiled build. Staging is decided by + content, not by name alone: a directory is staged only when it holds an + `about_*.help.txt`, a `*-help.xml` or a `*.psd1`, because `bin` is a real culture name + (Bini) and `ps` is Pashto and neither should be copied into a built module. + +- [**#211**](https://github.com/psake/PowerShellBuild/issues/211) + The built module no longer carries a stray copy of a culture directory's `.psd1` at its + root. The staging glob used `-Depth 1`, which recursed one level and matched + `en-US/Messages.psd1`, and the copy wrote it flat into the output root where nothing + reads it. On Windows PowerShell 5.1 it was worse: `-Depth` combined with `-Include` + degrades to a full `-Recurse` there, so files at any depth were flattened into the root + and same-named files at different depths could collide, which made the contents of a + published package depend on which host built it. The glob now matches the module root + only, in both modes. The same pattern in the readme discovery in `psakeFile.ps1` and + `IB.tasks.ps1` is fixed with it, where on 5.1 it walked the whole project root and + `Select-Object -First 1` then took an arbitrary readme. + +- [**#212**](https://github.com/psake/PowerShellBuild/issues/212) + Compile and non-compile mode now agree on what wins when a module has both a readme and + a hand-written about topic. The two used to disagree by accident of statement ordering — + the non-compile bulk copy runs after the readme block and overwrote the readme-derived + file, while in compile mode the readme landed last and replaced whatever + `CopyDirectories` had staged — so the winner depended on + `$PSBPreference.Build.CompileModule`, a setting with nothing to do with help. A source + about topic now wins in both modes, and a warning reports that the readme was not used. + Source wins because nothing is converted here: `ConvertReadMeToAboutHelp` copies the + Markdown as-is, and Markdown satisfies none of the structure `Get-Help` documents for an + about topic, so letting the readme win would replace conformant help with content + `Get-Help` cannot present. + - [**#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..c3d9af4 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. +- [Staging no longer reaches below the module root](#staging-no-longer-reaches-below-the-module-root) + — a stray copy of a culture directory's `.psd1` disappears from the output + root, and your readme now has to be at the project root to be found. +- [A hand-written about topic now wins over `ConvertReadMeToAboutHelp`](#a-hand-written-about-topic-now-wins-over-convertreadmetoabouthelp) + — the readme no longer replaces a conformant about topic, and a warning + says when it was skipped. ## AI-assisted migration @@ -1012,6 +1018,92 @@ Note the two source families differ, and the difference matters: Tracked in [#193](https://github.com/psake/PowerShellBuild/issues/193). +### Staging no longer reaches below the module root + +**Affects every consumer whose module source has a culture directory, and +every consumer whose readme is not at the project root.** + +Staging copied the module's loose files with `-Depth 1`, which recurses one +level down. Two things followed from that. + +**Your output root loses a file it should never have had.** A module with a +localized `en-US/Messages.psd1` matched the depth-1 glob, and the copy wrote +it flat into the output root — a second copy of the file, at a path nothing +reads. If `$PSBPreference.Build.CopyDirectories` also named the culture +directory, the correct copy was there too and the published package carried +both. The stray copy is gone in 1.0.0. Nothing read it, so nothing should +break; if a script of yours reads +`/Messages.psd1`, point it at +`//Messages.psd1` instead. + +**On Windows PowerShell 5.1 the change is larger.** There, `-Depth` +combined with `-Include` degrades to a full `-Recurse`, so files at *any* +depth below your module source were flattened into the output root, and +same-named files at different depths overwrote each other. The contents of +a published package therefore depended on which host built it. If you build +on 5.1 and your output root has `.psd1`, `.psm1` or `.ps1xml` files you did +not expect, that is where they came from, and they will be absent from +1.0.0 builds. + +The same pattern was in the readme discovery in both task files, so +`$PSBPreference.Help.ConvertReadMeToAboutHelp` also changes: + +**Before (0.8.x):** the readme was searched for at the project root *and one +level below it*, and on Windows PowerShell 5.1 through the entire project +root, with `Select-Object -First 1` taking whichever the enumeration reached +first — which could be a readme in a subdirectory, or one in a previous +build's output. + +**After (1.0.0):** only `readme.md`, `readme.markdown` or `readme.txt` +directly in `$PSBPreference.General.ProjectRoot` is used. + +**Detection:** if your about help stops being generated after upgrading, +your readme is not at the project root. Move it there, or call +`Build-PSBuildModule -ReadMePath` yourself with the path you want. + +Compile mode gains the other half of the same fix: it now stages a source +culture directory on its own, rather than only when `CopyDirectories` +happened to name it, so a compiled module ships the about topic and +localized data it always should have. See +[#210](https://github.com/psake/PowerShellBuild/issues/210). + +Tracked in [#211](https://github.com/psake/PowerShellBuild/issues/211). + +### A hand-written about topic now wins over `ConvertReadMeToAboutHelp` + +**Only affects builds that set +`$PSBPreference.Help.ConvertReadMeToAboutHelp = $true` (or pass +`-ReadMePath`) while also shipping a hand-written +`/about_.help.txt` in their module source.** + +The two build modes used to disagree about which one won, by accident of +statement ordering rather than by design: in non-compile mode the bulk copy +runs after the readme block and overwrote the readme-derived file, so the +source topic won; in compile mode the readme ran last, so it won. The answer +depended on `$PSBPreference.Build.CompileModule`, a setting with nothing to +do with help. + +**After (1.0.0):** the source about topic wins in both modes, and the build +warns that the readme was not converted: + + WARNING: The source tree already provides an about help topic at + [./MyModule/en-US/about_MyModule.help.txt], so the readme at + [./README.md] was not converted into about help. + +The readme loses because nothing here converts anything — +`ConvertReadMeToAboutHelp` is a plain copy of the Markdown, and Markdown +satisfies none of the `TOPIC` and four-space-indent structure `Get-Help` +documents for an about topic. Letting it win would replace conformant help +with content `Get-Help` cannot present. + +**If you want the readme to be your about topic**, delete the hand-written +`/about_.help.txt` from your module source. **If you want +the hand-written topic**, stop setting `ConvertReadMeToAboutHelp` and the +warning goes away. The rule is per culture: a hand-written `en-US` topic +does not stop the readme becoming the `fr-FR` one. + +Tracked in [#212](https://github.com/psake/PowerShellBuild/issues/212). + ## Adding an entry (for PR contributors) Every breaking-change PR that lands in v1.0.0 must add an entry here for