From 8f89e1301eeb8478f4d58bb9dd2b9167dcd6f42b Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 27 Aug 2026 14:45:37 -0400 Subject: [PATCH 1/3] fix: Resolve localized strings from the shipped file on every host PowerShellBuild.psm1 carried a hand-written second copy of every user-facing string in an inline data block, introduced with the comment "Load here in case Import-LocalizedData is not available". The copy had drifted sixteen strings behind en-US/Messages.psd1 -- every certificate and signing message was absent, along with HelpInfoUriRequired, ModuleLandingPageNotFound and ScriptAnalyzerRuleErrorRetry, and PesterVersionNotSupported still named the pre-#182 floor of 5.0.0. The copy was reachable, not dead. Import-LocalizedData runs with -ErrorAction SilentlyContinue, so a lookup that resolves nothing binds the inline copy silently. Probing a module with en-US and de-DE string files from both supported hosts shows they do not agree: pwsh 7.5 powershell.exe 5.1.26100.9168 de-AT -> en-US file de-AT -> inline copy fr-FR -> en-US file fr-FR -> inline copy ja-JP -> en-US file ja-JP -> inline copy PowerShell 7 has a final en-US fallback -- hiding the en-US directory sends fr-FR to the inline copy there too -- and Windows PowerShell 5.1 has none. Against the real module on 5.1 under fr-FR, $LocalizedData held 25 of the 41 shipped strings and reported a Pester floor of 5.0.0. Windows PowerShell 5.1 is a supported host and non-English Windows installations are common. A missing key does not throw: $null -f $arguments returns an empty string. So the symptom was Build-PSBuildUpdatableHelp emitting blank WARNING lines and the signing tasks raising a bare ScriptHalted instead of saying no certificate was found. The inline copy is deleted rather than resynchronized, because a hand-maintained duplicate will drift again, and the comment it was written under guards against nothing: Import-LocalizedData ships in Microsoft.PowerShell.Utility and has since PowerShell 3.0. en-US is now requested by name when the culture lookup binds nothing, with -ErrorAction Stop on that fallback so a module genuinely missing its strings fails at import instead of blanking every message. tests/LocalizedData.tests.ps1 pins both halves. The drift guard asserts the module file defines no string table of its own, and a child-process probe per supported host asserts that importing under a fr-FR UI culture binds every shipped string, key for key. Against the unfixed module the drift guard failed and the Windows PowerShell leg reported 25 keys against 41 expected; the PowerShell 7 leg passed either way, which is exactly why the 5.1 leg is there. Closes #185 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- CHANGELOG.md | 16 +++ PowerShellBuild/PowerShellBuild.psm1 | 47 +++----- tests/LocalizedData.tests.ps1 | 163 +++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 32 deletions(-) create mode 100644 tests/LocalizedData.tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baa132..5a47436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- [**#185**](https://github.com/psake/PowerShellBuild/issues/185) + Consumers on a non-English Windows PowerShell 5.1 host get the module's + real messages. `PowerShellBuild.psm1` carried a hand-maintained second copy + of every string, bound whenever `Import-LocalizedData` resolved nothing, and + it had drifted sixteen strings behind `en-US/Messages.psd1` — every + certificate and signing message was absent, and the Pester floor was still + reported as `5.0.0`. That lookup resolves by UI culture and the module ships + `en-US` only: PowerShell 7 falls back to `en-US` and never saw the drift, + but Windows PowerShell 5.1 does not fall back at all, so a French or + Japanese install bound the stale copy. A missing string does not throw, so + the symptom was a blank `WARNING:` line, or a bare `ScriptHalted` where the + signing tasks meant to say no certificate was found. The copy is gone, + `en-US` is now requested by name when the culture lookup misses, and an + import that cannot find the strings at all fails outright rather than + blanking every message. + - [**#124**](https://github.com/psake/PowerShellBuild/issues/124) The docs tree can hold documentation that is not generated help. A `README.md` at its root, a `CONTRIBUTING.md` beside the generated markdown, an `images/` or diff --git a/PowerShellBuild/PowerShellBuild.psm1 b/PowerShellBuild/PowerShellBuild.psm1 index d6ebe19..faa2b88 100644 --- a/PowerShellBuild/PowerShellBuild.psm1 +++ b/PowerShellBuild/PowerShellBuild.psm1 @@ -9,42 +9,25 @@ foreach ($import in $public + $private) { } } -data LocalizedData { - # Load here in case Import-LocalizedData is not available - ConvertFrom-StringData @' -NoCommandsExported=No commands have been exported. Skipping markdown generation. -FailedToGenerateMarkdownHelp=Failed to generate markdown help. : {0} -AddingFileToPsm1=Adding [{0}] to PSM1 -MakeCabNotAvailable=MakeCab.exe is not available. Cannot create help cab. -DirectoryAlreadyExists=Directory already exists [{0}]. -PathLongerThan3Chars=Path [{0}] must be longer than 3 characters. -BuildSystemDetails=Build System Details: -BuildModule=Build Module: {0}:{1} -PowerShellVersion=PowerShell Version: {0} -EnvironmentVariables={0}Environment variables: -PublishingVersionToRepository=Publishing version [{0}] to repository [{1}]... -FolderDoesNotExist=Folder does not exist: {0} -PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. -UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module -PesterTestsFailed=One or more Pester tests failed -PesterVersionNotSupported=Pester version [{0}] is loaded, but Test-PSBuildPester requires Pester 5.0.0 or newer. -CodeCoverage=Code Coverage -Type=Type -CodeCoverageLessThanThreshold=Code coverage: [{0}] is [{1:p}], which is less than the threshold of [{2:p}] -CodeCoverageCodeCoverageFileNotFound=Code coverage file [{0}] not found. -SeverityThresholdSetTo=SeverityThreshold set to: {0} -PSScriptAnalyzerResults=PSScriptAnalyzer results: -ScriptAnalyzerErrors=One or more ScriptAnalyzer errors were found! -ScriptAnalyzerWarnings=One or more ScriptAnalyzer warnings were found! -ScriptAnalyzerIssues=One or more ScriptAnalyzer issues were found! -'@ -} -$importLocalizedDataSplat = @{ +# Every user-facing string lives in en-US/Messages.psd1 and nowhere else. Import-LocalizedData +# resolves that file through the current UI culture and the culture's parent chain, so the lookup +# misses whenever the machine's UI culture has no directory of its own -- and this module ships +# en-US only. PowerShell 7 has a final en-US fallback that hides the miss; Windows PowerShell 5.1 +# has none and binds nothing at all, which is why a French or Japanese Windows install got no +# strings. Ask for en-US by name when the culture lookup comes up empty, and fail the import if +# even that is not there: silent, missing strings turn every message into an empty warning or a +# bare ScriptHalted (psake/PowerShellBuild#185). +$importLocalizedDataParameters = @{ BindingVariable = 'LocalizedData' FileName = 'Messages.psd1' ErrorAction = 'SilentlyContinue' } -Import-LocalizedData @importLocalizedDataSplat +Import-LocalizedData @importLocalizedDataParameters +if (-not $LocalizedData) { + $importLocalizedDataParameters['UICulture'] = 'en-US' + $importLocalizedDataParameters['ErrorAction'] = 'Stop' + Import-LocalizedData @importLocalizedDataParameters +} Export-ModuleMember -Function $public.Basename diff --git a/tests/LocalizedData.tests.ps1 b/tests/LocalizedData.tests.ps1 new file mode 100644 index 0000000..cfd4bc3 --- /dev/null +++ b/tests/LocalizedData.tests.ps1 @@ -0,0 +1,163 @@ +# Coverage for how the module resolves its user-facing strings at import +# (psake/PowerShellBuild#185). +# +# PowerShellBuild.psm1 used to carry a second, hand-written copy of every string in an inline +# `data LocalizedData { ConvertFrom-StringData ... }` block, kept for the case where +# Import-LocalizedData resolved nothing. The import runs with -ErrorAction SilentlyContinue, so a +# lookup that misses binds that copy without saying anything -- and the copy went sixteen strings +# stale, still naming the pre-#182 Pester floor of 5.0.0. +# +# The lookup misses more often than it looks. Import-LocalizedData resolves Messages.psd1 through +# the current UI culture and that culture's parent chain, and the module ships en-US only. +# PowerShell 7 has a final en-US fallback, so it always lands on the shipped file; Windows +# PowerShell 5.1 has no such fallback, so on a machine whose display language is French or +# Japanese the lookup binds nothing at all and the stale copy is what consumers got. 5.1 is a +# supported host (`PowerShellVersion = '5.1'`), which is what made the drift reachable rather +# than theoretical. +# +# Two things are pinned here. The first It is the drift guard: one definition of the strings, so +# there is no second copy left to fall out of step. The Context below is the behavioral +# guarantee, and it runs in a child process per supported host -- a UI culture has to be in place +# before the module is imported, and setting it in-process would leak into the rest of the suite. + +BeforeDiscovery { + # Probe the host running the suite, plus Windows PowerShell when it is installed, because the + # two do not agree about the en-US fallback and only the 5.1 leg catches the regression. The + # current process is used for the first leg rather than a PATH lookup so this always produces + # at least one leg, whichever host the suite was started with; identical paths collapse. + $currentHostPath = [System.Diagnostics.Process]::GetCurrentProcess().Path + $windowsPowerShellPath = ( + Get-Command -Name 'powershell.exe' -CommandType 'Application' -ErrorAction SilentlyContinue | + Select-Object -First 1 + ).Source + + $script:localizationHost = @( + $currentHostPath, $windowsPowerShellPath | + Where-Object { $_ } | + Select-Object -Unique | + ForEach-Object { @{ HostName = [IO.Path]::GetFileName($_); HostPath = $_ } } + ) + if ($script:localizationHost.Count -eq 0) { + # An empty -ForEach collection throws during discovery, which reads as a file that + # silently disappeared. Say what actually went wrong instead. + throw 'Could not resolve the path of the host running this suite; no host can be probed.' + } +} + +Describe 'Localized string resolution' { + + BeforeAll { + $script:repositoryRoot = Split-Path -Path $PSScriptRoot -Parent + $script:sourceModulePath = [IO.Path]::Combine( + $script:repositoryRoot, 'PowerShellBuild', 'PowerShellBuild.psm1' + ) + + # Probe the built module rather than the source tree: it is what a consumer installs, and + # the versioned directory is the -BaseDirectory Import-LocalizedData needs. + $sourceManifestPath = [IO.Path]::Combine( + $script:repositoryRoot, 'PowerShellBuild', 'PowerShellBuild.psd1' + ) + $moduleVersion = (Import-PowerShellDataFile -Path $sourceManifestPath).ModuleVersion + $script:builtModulePath = [IO.Path]::Combine( + $script:repositoryRoot, 'Output', 'PowerShellBuild', $moduleVersion + ) + # The probe imports the .psm1 directly, not the manifest. The manifest's RequiredModules + # are resolved against the child process's inherited PSModulePath, which on Windows + # PowerShell means the PowerShell 7 module directories it was spawned from -- a failure + # that has nothing to do with which strings got bound. The .psm1 is the file under test. + $script:builtModuleFilePath = Join-Path -Path $script:builtModulePath -ChildPath 'PowerShellBuild.psm1' + + # The strings as shipped. Read through Import-LocalizedData because Messages.psd1 is a + # ConvertFrom-StringData document rather than a hashtable literal, so + # Import-PowerShellDataFile cannot read it. + $importShippedStringParameters = @{ + BindingVariable = 'shippedString' + BaseDirectory = $script:builtModulePath + FileName = 'Messages.psd1' + UICulture = 'en-US' + ErrorAction = 'Stop' + } + Import-LocalizedData @importShippedStringParameters + $script:shippedString = $shippedString + + # The probe reports what the module bound at import, from inside the module's own scope. + # Written to $TestDrive rather than checked in, following the convention in + # Test-PSBuildPester.tests.ps1 for generated fixtures. + $script:probeScriptPath = Join-Path -Path $TestDrive -ChildPath 'Get-ResolvedString.ps1' + Set-Content -Path $script:probeScriptPath -Value @' +param( + [Parameter(Mandatory)] + [string] + $ModuleFilePath, + + [Parameter(Mandatory)] + [string] + $UICulture +) + +# Set before the import, because Import-LocalizedData reads the UI culture as the module loads. +# This is the state a machine whose Windows display language is not English starts up in. +[System.Threading.Thread]::CurrentThread.CurrentUICulture = + [System.Globalization.CultureInfo]::new($UICulture) + +Import-Module -Name $ModuleFilePath -Force -WarningAction SilentlyContinue -ErrorAction Stop +$resolvedString = & (Get-Module -Name 'PowerShellBuild') { $LocalizedData } +$resolvedString | ConvertTo-Json -Depth 2 -Compress +'@ + } + + It 'defines its user-facing strings in exactly one place' { + # en-US/Messages.psd1 is the only place a string may be written. A second copy inside the + # module file cannot be kept in step by hand, which is the whole of #185. + $moduleSource = Get-Content -Path $script:sourceModulePath -Raw + $moduleSource | Should -Not -Match 'ConvertFrom-StringData' + } + + Context 'Imported by <_.HostName> under a non-English UI culture' -ForEach $script:localizationHost { + + BeforeAll { + # fr-FR is chosen because the module ships no fr-FR directory and none of its parents + # is en-US, so nothing but a deliberate fallback can reach the shipped strings. + $probeArgument = @( + '-NoProfile' + '-NonInteractive' + '-File', $script:probeScriptPath + '-ModuleFilePath', $script:builtModuleFilePath + '-UICulture', 'fr-FR' + ) + $probeOutput = & $_.HostPath @probeArgument 2>&1 + + # The probe writes one JSON document and nothing else. Selecting the line rather than + # taking all output keeps a stray host warning from breaking the parse. + $probeJson = $probeOutput | + Where-Object { $_ -is [string] -and $_.TrimStart().StartsWith('{') } | + Select-Object -Last 1 + + $script:probeOutput = $probeOutput + $script:resolvedString = if ($probeJson) { $probeJson | ConvertFrom-Json } + } + + It 'reports a string table from the probe' { + # Guards the two assertions below: a probe that produced nothing would otherwise + # satisfy a foreach over an empty set and prove nothing. + $script:resolvedString | + Should -Not -BeNullOrEmpty -Because "the probe wrote: $($script:probeOutput -join '; ')" + } + + It 'binds every string the module ships' { + $resolvedKey = $script:resolvedString.PSObject.Properties.Name | Sort-Object + $shippedKey = $script:shippedString.Keys | Sort-Object + $resolvedKey | Should -Be $shippedKey + } + + It 'binds each string to its shipped text' { + # Key-for-key rather than a count, because the failure this pins was a copy that had + # the right shape and the wrong words -- it named a Pester floor of 5.0.0 long after + # #182 raised the real one to 6.0.0. + foreach ($key in $script:shippedString.Keys) { + $script:resolvedString.$key | + Should -Be $script:shippedString[$key] -Because "[$key] must match en-US/Messages.psd1" + } + } + } +} From d9d4918bebb1ccc783112f393d1b14a91f16095a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 27 Aug 2026 14:55:05 -0400 Subject: [PATCH 2/3] test: Collapse the duplicate host leg when the suite runs on Windows PowerShell Select-Object -Unique compares case-sensitively, so the CI leg that runs the suite under Windows PowerShell 5.1 probed powershell.EXE and powershell.exe as two hosts -- the same executable spelled two ways by the process path and the PATH lookup. Sort-Object -Unique collapses them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- tests/LocalizedData.tests.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/LocalizedData.tests.ps1 b/tests/LocalizedData.tests.ps1 index cfd4bc3..adf5b47 100644 --- a/tests/LocalizedData.tests.ps1 +++ b/tests/LocalizedData.tests.ps1 @@ -31,10 +31,13 @@ BeforeDiscovery { Select-Object -First 1 ).Source + # Sort-Object -Unique rather than Select-Object -Unique: when the suite is running under + # Windows PowerShell the two lookups return the same executable spelled differently + # (powershell.EXE and powershell.exe), and only Sort-Object collapses that by default. $script:localizationHost = @( $currentHostPath, $windowsPowerShellPath | Where-Object { $_ } | - Select-Object -Unique | + Sort-Object -Unique | ForEach-Object { @{ HostName = [IO.Path]::GetFileName($_); HostPath = $_ } } ) if ($script:localizationHost.Count -eq 0) { From 0d3a66a43c676c9d512be7bd93244b55e4573f02 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 27 Aug 2026 16:38:31 -0400 Subject: [PATCH 3/3] fix: Ship the one string the module read and never defined Publish-PSBuildModule validates -Path against $LocalizedData.PathDoesNotExist in a ValidateScript on a mandatory parameter. en-US/Messages.psd1 never defined that key, so the lookup returned $null, $null -f $argument is an empty string rather than an error, and passing a path that does not exist failed validation with no text at all -- while the very next check in the same script block reported itself properly. Unlike the drift this branch already fixes, that one was not confined to Windows PowerShell on a non-English machine. A key the file never defines is missing on every host and every culture. The new assertion is the other half of the promise this branch makes. The existing guard keeps a second copy of the strings from existing; this one keeps the single copy complete, by scanning the module source for every $LocalizedData reference and requiring Messages.psd1 to define it. The sweep found exactly one missing key and no unused ones, and it is worth having standing: #194 is adding a new string right now. Caught in review of #187. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE --- CHANGELOG.md | 8 ++++++++ PowerShellBuild/en-US/Messages.psd1 | 3 ++- tests/LocalizedData.tests.ps1 | 31 ++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a47436..4317b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). import that cannot find the strings at all fails outright rather than blanking every message. + The same sweep found one string the module read and never shipped: + `Publish-PSBuildModule` validated `-Path` against `PathDoesNotExist`, which + `en-US/Messages.psd1` did not define, so passing a path that does not exist + failed with a blank message on **every** host and culture while the very + next check in the same validation block reported itself properly. The string + is now defined, and a test asserts that every string the module reads is one + it ships. + - [**#124**](https://github.com/psake/PowerShellBuild/issues/124) The docs tree can hold documentation that is not generated help. A `README.md` at its root, a `CONTRIBUTING.md` beside the generated markdown, an `images/` or diff --git a/PowerShellBuild/en-US/Messages.psd1 b/PowerShellBuild/en-US/Messages.psd1 index 0feb84b..b2ef48e 100644 --- a/PowerShellBuild/en-US/Messages.psd1 +++ b/PowerShellBuild/en-US/Messages.psd1 @@ -1,4 +1,4 @@ -ConvertFrom-StringData @' +ConvertFrom-StringData @' NoCommandsExported=No commands have been exported. Skipping markdown generation. FailedToGenerateMarkdownHelp=Failed to generate markdown help. : {0} AddingFileToPsm1=Adding [{0}] to PSM1 @@ -13,6 +13,7 @@ PowerShellVersion=PowerShell Version: {0} EnvironmentVariables={0}Environment variables: PublishingVersionToRepository=Publishing version [{0}] to repository [{1}]... FolderDoesNotExist=Folder does not exist: {0} +PathDoesNotExist=Path does not exist: {0} PathArgumentMustBeAFolder=The Path argument must be a folder. File paths are not allowed. UnableToFindModuleManifest=Unable to find module manifest [{0}]. Can't import module PesterTestsFailed=One or more Pester tests failed diff --git a/tests/LocalizedData.tests.ps1 b/tests/LocalizedData.tests.ps1 index adf5b47..d0d084c 100644 --- a/tests/LocalizedData.tests.ps1 +++ b/tests/LocalizedData.tests.ps1 @@ -1,4 +1,4 @@ -# Coverage for how the module resolves its user-facing strings at import +# Coverage for how the module resolves its user-facing strings at import # (psake/PowerShellBuild#185). # # PowerShellBuild.psm1 used to carry a second, hand-written copy of every string in an inline @@ -116,6 +116,35 @@ $resolvedString | ConvertTo-Json -Depth 2 -Compress $moduleSource | Should -Not -Match 'ConvertFrom-StringData' } + It 'ships every string the module asks for' { + # The guard above keeps a second copy from existing. This one keeps the single copy + # complete, which is the other half of the same promise and fails differently: a key + # the module reads but Messages.psd1 never defines resolves to $null, and $null -f + # $argument is an empty string rather than an error. The message simply comes out + # blank -- on every host and every culture, not just the ones the Context below + # covers. + # + # That is not hypothetical. Publish-PSBuildModule read PathDoesNotExist from a + # ValidateScript on a mandatory parameter while Messages.psd1 never defined it, so + # passing a path that did not exist failed validation with no text at all, while the + # very next check in the same script block reported itself properly. + $sourceFile = Get-ChildItem -Path ( + [IO.Path]::Combine($script:repositoryRoot, 'PowerShellBuild') + ) -Include '*.ps1', '*.psm1' -File -Recurse + + $referencedKey = @( + $sourceFile | + Select-String -Pattern '\$LocalizedData\.(\w+)' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $_.Groups[1].Value } | + Sort-Object -Unique + ) + $referencedKey | Should -Not -BeNullOrEmpty -Because 'the pattern must still match something' + + $undefined = @($referencedKey.Where({ $_ -notin $script:shippedString.Keys })) + $undefined -join ', ' | Should -BeNullOrEmpty + } + Context 'Imported by <_.HostName> under a non-English UI culture' -ForEach $script:localizationHost { BeforeAll {