Browse Source

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 <ai@example.com>
pull/117/head
Raimund Andree 3 days ago
parent
commit
95b827e911
  1. 105
      .github/scripts/Publish-ModulePackage.ps1
  2. 14
      .github/workflows/ci.yml
  3. 13
      Docs/Contributing/05-Releasing.md
  4. 188
      Tests/Publish-ModulePackage.Tests.ps1

105
.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
}

14
.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

13
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:

188
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: <Case>' -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
}
}
Loading…
Cancel
Save