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 + } +}