From 95b827e911b7c6a2662a2d1c70bf86ec02a4aec2 Mon Sep 17 00:00:00 2001 From: Raimund Andree Date: Fri, 9 Oct 2026 08:22:22 +0000 Subject: [PATCH] fix(ci): verify package identity before publication recovery A Gallery upload can succeed while PSResourceGet reports a timeout or a 409 from its retry. Recover that uncertain outcome only after the published SHA512 matches the exact build artifact. Verify the same hash before skipping an existing version, and preserve the original upload error when the version is absent, different, or unverifiable. Keep API keys in the existing environment secret. Unexpected discovery errors fail instead of silently proceeding. Offline tests reproduce legacy false failures and blind skips; all 14 tests pass in each edition and privilege configuration. No real package was published by tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: AI Assistant --- .github/scripts/Publish-ModulePackage.ps1 | 105 ++++++++++++ .github/workflows/ci.yml | 14 +- Docs/Contributing/05-Releasing.md | 13 +- Tests/Publish-ModulePackage.Tests.ps1 | 188 ++++++++++++++++++++++ 4 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 .github/scripts/Publish-ModulePackage.ps1 create mode 100644 Tests/Publish-ModulePackage.Tests.ps1 diff --git a/.github/scripts/Publish-ModulePackage.ps1 b/.github/scripts/Publish-ModulePackage.ps1 new file mode 100644 index 0000000..ccd614e --- /dev/null +++ b/.github/scripts/Publish-ModulePackage.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + Publishes the already built NTFSSecurity package to the PowerShell Gallery. +.DESCRIPTION + Uses the PSGALLERY_API_KEY environment secret. A published version is skipped only when its Gallery SHA512 + matches the exact local package. An uncertain upload is recovered only after that same verification; other + errors remain failures. The release workflow checks the tag and version first. +.PARAMETER NupkgPath + The package that the build job produced. +.PARAMETER Version + The version that the release workflow verified. +.EXAMPLE + .\.github\scripts\Publish-ModulePackage.ps1 -NupkgPath .\out\NTFSSecurity.5.0.0-rc7.nupkg -Version 5.0.0-rc7 + + Publishes the package using the environment secret, without logging or passing the key on a process command line. +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] + [string] $NupkgPath, + + [Parameter(Mandatory)] + [ValidatePattern('\A\d+\.\d+\.\d+(?:-[A-Za-z][0-9A-Za-z-]*)?\z')] + [string] $Version +) + +$ErrorActionPreference = 'Stop' +if (-not $env:PSGALLERY_API_KEY) { + throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.' +} +$packagePath = (Resolve-Path -LiteralPath $NupkgPath).ProviderPath + +function Find-PublishedPackage { + [CmdletBinding()] + [OutputType([psobject])] + param () + + $lookupErrors = @() + $found = @(Find-PSResource -Name NTFSSecurity -Version $Version -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue -ErrorVariable lookupErrors) + foreach ($lookupError in $lookupErrors) { + if (($lookupError.FullyQualifiedErrorId -split ',')[0] -ne 'PackageNotFound') { + throw $lookupError + } + } + if ($found.Count -gt 1) { + throw "The PowerShell Gallery returned more than one package for NTFSSecurity $Version." + } + if ($found.Count -eq 1) { + return $found[0] + } + Write-Verbose "NTFSSecurity $Version is not listed in the PowerShell Gallery." +} + +function Assert-PublishedPackage { + [CmdletBinding()] + param () + + $uri = "https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='$Version')" + $entry = Invoke-RestMethod -Uri $uri -ErrorAction Stop + $expectedHash = [string] $entry.entry.properties.PackageHash + if ($entry.entry.properties.PackageHashAlgorithm -ne 'SHA512' -or -not $expectedHash) { + throw "The PowerShell Gallery has no usable SHA512 hash for NTFSSecurity $Version." + } + $stream = [IO.File]::OpenRead($packagePath) + $sha512 = [Security.Cryptography.SHA512]::Create() + try { + $actualHash = [Convert]::ToBase64String($sha512.ComputeHash($stream)) + } + finally { + $sha512.Dispose() + $stream.Dispose() + } + if ($actualHash -cne $expectedHash) { + throw "NTFSSecurity $Version in the PowerShell Gallery contains a different package; publication cannot continue." + } +} + +if (Find-PublishedPackage) { + Assert-PublishedPackage + "NTFSSecurity $Version is already in the PowerShell Gallery and matches the exact local package." + return +} + +try { + Publish-PSResource -NupkgPath $packagePath -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY -ErrorAction Stop +} +catch { + $publishError = $_ + $verified = $false + try { + if (Find-PublishedPackage) { + Assert-PublishedPackage + $verified = $true + } + } + catch { + Write-Warning ("The upload outcome could not be verified for NTFSSecurity {0}: {1}" -f $Version, $_.Exception.Message) + } + if ($verified) { + Write-Warning "Publish-PSResource reported an error, but the Gallery SHA512 verified the exact package for NTFSSecurity $Version." + return + } + throw $publishError +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2c63d5..96511e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -295,8 +295,8 @@ jobs: "notes=$notes" ) - # Publishes the package that the build job built and tested. A rerun skips - # a version that the PowerShell Gallery already has. + # Publish the tested package; recovery and reruns verify the Gallery SHA512 + # so a different package with the same version cannot count as success. - name: Publish to the PowerShell Gallery shell: pwsh env: @@ -304,15 +304,7 @@ jobs: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_PACKAGE: ${{ steps.release.outputs.package }} run: | - if (-not $env:PSGALLERY_API_KEY) { - throw 'The secret PSGALLERY_API_KEY of the environment powershell-gallery is not set.' - } - $published = Find-PSResource -Name NTFSSecurity -Version $env:RELEASE_VERSION -Prerelease -Repository PSGallery -ErrorAction SilentlyContinue - if ($published) { - "NTFSSecurity $env:RELEASE_VERSION is already in the PowerShell Gallery." - exit 0 - } - Publish-PSResource -NupkgPath $env:RELEASE_PACKAGE -Repository PSGallery -ApiKey $env:PSGALLERY_API_KEY + & ./.github/scripts/Publish-ModulePackage.ps1 -NupkgPath $env:RELEASE_PACKAGE -Version $env:RELEASE_VERSION - name: Create the GitHub release shell: pwsh diff --git a/Docs/Contributing/05-Releasing.md b/Docs/Contributing/05-Releasing.md index 0ed71ba..7ac8365 100644 --- a/Docs/Contributing/05-Releasing.md +++ b/Docs/Contributing/05-Releasing.md @@ -99,11 +99,20 @@ describes, with `-Version` instead of `-ModulePath`. ## If a release fails -The **Release** job skips what's already done: a version that the PowerShell -Gallery already has, and a GitHub release that already exists. If the +The **Release** job skips a version that the PowerShell Gallery already has +only after its published SHA-512 matches the exact package from the build +artifact. It also skips a GitHub release that already exists. If the failure doesn't need a change in the repository, fix the cause and rerun the failed job. +An upload can report an error even after the Gallery accepted it, for +example a timeout followed by HTTP 409 (version already exists). The +publication script checks the Gallery once more and recovers only if the +published SHA-512 verifies the exact local package. A missing version, +unavailable metadata, or a different package remains a failure; an existing +version alone is not proof of success. The API key stays in the +`powershell-gallery` environment secret. + If the fix needs a change in the repository and the PowerShell Gallery doesn't have the version yet, delete the tag, merge the fix, and tag the new commit: diff --git a/Tests/Publish-ModulePackage.Tests.ps1 b/Tests/Publish-ModulePackage.Tests.ps1 new file mode 100644 index 0000000..c91d5dd --- /dev/null +++ b/Tests/Publish-ModulePackage.Tests.ps1 @@ -0,0 +1,188 @@ +<# + Tests Gallery publication recovery offline. Every network and publication command is mocked; the fake API key + exists only in the test process and is restored afterwards. Package hashes come from a sandbox file. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Pester shares variables between blocks.' +)] +param () + +BeforeAll { + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'TestHelpers.psm1') -Force + $sandbox = New-TestSandbox -Name 'PublishPackage' + $package = Join-Path -Path $sandbox -ChildPath 'NTFSSecurity.5.0.0-rc7.nupkg' + Assert-TestSandboxPath -Sandbox $sandbox -Path $package + [IO.File]::WriteAllBytes($package, [Text.Encoding]::UTF8.GetBytes('The package that CI built.')) + $sha512 = [Security.Cryptography.SHA512]::Create() + try { $hash = [Convert]::ToBase64String($sha512.ComputeHash([IO.File]::ReadAllBytes($package))) } + finally { $sha512.Dispose() } + $metadata = [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ + Id = 'NTFSSecurity'; Version = '5.0.0-rc7'; PackageHashAlgorithm = 'SHA512'; PackageHash = $hash + } } } + $published = [pscustomobject]@{ Name = 'NTFSSecurity'; Version = [version]'5.0.0'; Prerelease = 'rc7' } + $scriptPath = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\scripts\Publish-ModulePackage.ps1' + $originalKey = $env:PSGALLERY_API_KEY + + # Stubs keep these tests available in Windows PowerShell, where PSResourceGet might not be installed. + function Find-PSResource { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Command stub supplies parameter metadata for Pester mocks.' + )] + [CmdletBinding()] + param ([string] $Name, [string] $Version, [switch] $Prerelease, [string] $Repository) + throw 'Find-PSResource must be mocked in this test.' + } + function Publish-PSResource { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', Justification = 'Command stub supplies parameter metadata for Pester mocks.' + )] + [CmdletBinding()] + param ([string] $NupkgPath, [string] $Repository, [string] $ApiKey) + throw 'Publish-PSResource must be mocked in this test.' + } +} + +AfterAll { + $env:PSGALLERY_API_KEY = $originalKey + Remove-TestSandbox -Sandbox $sandbox +} + +Describe 'Publish-ModulePackage.ps1' { + BeforeEach { + $env:PSGALLERY_API_KEY = 'test-only-api-key' + Mock -CommandName Find-PSResource + Mock -CommandName Publish-PSResource + Mock -CommandName Invoke-RestMethod -MockWith { $metadata } + } + + It 'Should publish a new version using the environment key and the verified package path' { + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' + + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly -ParameterFilter { + $NupkgPath -eq $package -and $Repository -eq 'PSGallery' -and $ApiKey -eq 'test-only-api-key' + } + } + + It 'Should skip publication only after checking the existing package hash' { + Mock -CommandName Find-PSResource -MockWith { $published } + + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' + + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -eq "https://www.powershellgallery.com/api/v2/Packages(Id='NTFSSecurity',Version='5.0.0-rc7')" + } + } + + It 'Should refuse an existing version containing a different package' { + Mock -CommandName Find-PSResource -MockWith { $published } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = 'different-package' } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*different package*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should refuse metadata without a usable SHA512 package hash: ' -ForEach @( + @{ Case = 'missing hash'; Algorithm = 'SHA512'; PackageHash = '' } + @{ Case = 'wrong algorithm'; Algorithm = 'SHA256'; PackageHash = 'not-sha512' } + ) { + Mock -CommandName Find-PSResource -MockWith { $published } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = $Algorithm; PackageHash = $PackageHash } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*SHA512*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should recover an uncertain upload only when the exact package appears in the Gallery' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw '409: a package with this version already exists.' } + + & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningVariable uploadWarnings -WarningAction SilentlyContinue + + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + $uploadWarnings | Should -HaveCount 1 + $uploadWarnings[0].Message | Should -BeLike '*verified*exact package*' + } + + It 'Should preserve the upload error when the version remains absent' { + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: the server is unavailable.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*Upload failed: the server is unavailable*' + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + } + + It 'Should preserve the upload error when verification finds a different package' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: version collision.' } + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = 'different-package' } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: version collision*' + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + } + + It 'Should not publish after a lookup fails for a reason other than a missing version' { + Mock -CommandName Find-PSResource -MockWith { Write-Error -Message 'Lookup failed.' -ErrorId RepositoryUnavailable } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*Lookup failed*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should compare Base64 hashes case-sensitively' { + Mock -CommandName Find-PSResource -MockWith { $published } + $differentCase = $hash.ToLowerInvariant() + ($hash -ceq $differentCase) | Should -BeFalse + Mock -CommandName Invoke-RestMethod -MockWith { + [pscustomobject]@{ entry = [pscustomobject]@{ properties = [pscustomobject]@{ PackageHashAlgorithm = 'SHA512'; PackageHash = $differentCase } } } + } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*different package*' + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } + + It 'Should preserve the upload error when post-upload metadata is unavailable' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { $script:lookups++; if ($script:lookups -gt 1) { $published } } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: original error.' } + Mock -CommandName Invoke-RestMethod -MockWith { throw 'Metadata is unavailable.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: original error*' + Should -Invoke -CommandName Invoke-RestMethod -Times 1 -Exactly + } + + It 'Should preserve the upload error when the post-upload lookup fails' { + $script:lookups = 0 + Mock -CommandName Find-PSResource -MockWith { + $script:lookups++ + if ($script:lookups -gt 1) { Write-Error -Message 'Post-upload lookup failed.' -ErrorId RepositoryUnavailable } + } + Mock -CommandName Publish-PSResource -MockWith { throw 'Upload failed: original error.' } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' -WarningAction SilentlyContinue } | Should -Throw -ExpectedMessage '*Upload failed: original error*' + Should -Invoke -CommandName Find-PSResource -Times 2 -Exactly + } + It 'Should allow the expected PackageNotFound probe result before publishing' { + Mock -CommandName Find-PSResource -MockWith { Write-Error -Message 'Not published yet.' -ErrorId PackageNotFound } + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Not -Throw + Should -Invoke -CommandName Publish-PSResource -Times 1 -Exactly + } + + It 'Should reject a missing API key before contacting the Gallery' { + $env:PSGALLERY_API_KEY = $null + + { & $scriptPath -NupkgPath $package -Version '5.0.0-rc7' } | Should -Throw -ExpectedMessage '*PSGALLERY_API_KEY*not set*' + Should -Invoke -CommandName Find-PSResource -Times 0 -Exactly + Should -Invoke -CommandName Publish-PSResource -Times 0 -Exactly + } +}