mirror of https://github.com/raandree/NTFSSecurity
Browse Source
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
4 changed files with 307 additions and 13 deletions
@ -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 |
||||
|
} |
||||
@ -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…
Reference in new issue