From 3eba87855c43f7c3cd398a4b9c04f8e545e3ad36 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:40:22 +0100 Subject: [PATCH 01/21] Reproduce publication without source-bound preflight --- .../Publish-PSModule.Preflight.Tests.ps1 | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 .github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 new file mode 100644 index 00000000..4ff067bc --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -0,0 +1,280 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester setup variables are used by test blocks and command replacements.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeAll { + $script:environmentMutex = [Threading.Mutex]::new($false, "PSModule-ActionTests-Environment-$PID") + if (-not $script:environmentMutex.WaitOne([TimeSpan]::FromMinutes(5))) { + throw 'Cannot acquire the process environment lock for action tests.' + } + Import-Module -Name 'PSModule' -Force + + $script:publishScriptPath = [IO.Path]::GetFullPath( + [IO.Path]::Combine($PSScriptRoot, '..', 'src', 'publish.ps1') + ) + $script:environmentVariableNames = @( + 'GITHUB_EVENT_PATH' + 'GITHUB_REPOSITORY' + 'GITHUB_REPOSITORY_NAME' + 'GITHUB_SHA' + 'GITHUB_WORKSPACE' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_Name' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' + ) + $script:originalEnvironment = @{} + foreach ($name in $script:environmentVariableNames) { + $script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + $script:replacedCommands = @('gh', 'Find-PSResource', 'Publish-PSResource', 'Invoke-WebRequest') + $script:originalFunctions = @{} + foreach ($name in $script:replacedCommands) { + $function = Get-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue + $script:originalFunctions[$name] = $function.ScriptBlock + } +} + +AfterAll { + try { + foreach ($name in $script:environmentVariableNames) { + [Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + } + foreach ($name in $script:replacedCommands) { + Remove-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue + if ($null -ne $script:originalFunctions[$name]) { + Set-Item -Path "Function:global:$name" -Value $script:originalFunctions[$name] + } + } + } finally { + $script:environmentMutex.ReleaseMutex() + $script:environmentMutex.Dispose() + } +} + +Describe 'Publish-PSModule source-bound preflight' { + BeforeEach { + $script:modulePath = Join-Path -Path $TestDrive -ChildPath 'workspace' -AdditionalChildPath '.PSModule', 'module', 'TestModule' + $null = New-Item -Path $script:modulePath -ItemType Directory -Force + Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1') -Value '# Tested bytes.' + Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psd1') -Value @' +@{ + RootModule = 'TestModule.psm1' + ModuleVersion = '1.2.4' + GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' + Author = 'PSModule' + Description = 'Anonymous publication fixture' + PrivateData = @{ + ConsumerMetadata = 'Preserve this value.' + PSData = @{ Prerelease = '' } + } +} +'@ + $eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json' + @{ head_commit = @{ message = 'Anonymous release notes.' } } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $eventPath + + $env:GITHUB_EVENT_PATH = $eventPath + $env:GITHUB_REPOSITORY = 'PSModule/TestModule' + $env:GITHUB_SHA = '1111111111111111111111111111111111111111' + $env:GITHUB_WORKSPACE = Join-Path -Path $TestDrive -ChildPath 'workspace' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_Name = 'TestModule' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath = '.PSModule/module' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'anonymous-fixture' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' + + $state = @{ + Calls = [Collections.Generic.List[string]]::new() + GalleryWrites = 0 + GitHubWrites = 0 + Tag = $null + TagObject = $null + Release = $null + Package = $null + ApiError = '' + } + $script:state = $state + + Set-Item -Path function:global:Find-PSResource -Value { + [CmdletBinding()] + param( + [string] $Name, + [string] $Version, + [string] $Repository + ) + if ($Name -cne 'TestModule' -or $Version -cne '1.2.4' -or $Repository -cne 'PSGallery') { + throw "Unexpected exact package lookup: [$Name] [$Version] [$Repository]." + } + $state.Calls.Add('gallery-read') + if ($null -ne $state.Package) { + return $state.Package + } + Write-Error -Message 'The exact fixture package is absent.' -ErrorId PackageNotFound -Category ObjectNotFound + }.GetNewClosure() + + Set-Item -Path function:global:Publish-PSResource -Value { + $state.Calls.Add('gallery-write') + $state.GalleryWrites++ + }.GetNewClosure() + + Set-Item -Path function:global:Invoke-WebRequest -Value { + throw 'Package bytes must come from an explicitly injected local fixture, never a network request.' + } + + Set-Item -Path function:global:gh -Value { + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + $state.Calls.Add("github: $($Arguments -join ' ')") + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'api' -and $Arguments -notcontains 'POST' -and $Arguments -notcontains 'PATCH') { + if ($state.ApiError) { + $global:LASTEXITCODE = 1 + return $state.ApiError + } + if ($Arguments[1] -like '*/git/ref/tags/*') { + if ($null -ne $state.Tag) { + return (@{ object = $state.Tag } | ConvertTo-Json -Depth 10 -Compress) + } + } elseif ($Arguments[1] -like '*/git/tags/*') { + return ($state.TagObject | ConvertTo-Json -Depth 10 -Compress) + } elseif ($Arguments[1] -like '*/releases/tags/*') { + if ($null -ne $state.Release) { + return ($state.Release | ConvertTo-Json -Depth 10 -Compress) + } + } else { + throw "Unexpected read in publication fixture: $($Arguments -join ' ')." + } + $global:LASTEXITCODE = 1 + return 'gh: Not Found (HTTP 404)' + } + $state.GitHubWrites++ + throw "Unexpected mutation in a rejected publication fixture: $($Arguments -join ' ')." + }.GetNewClosure() + } + + It 'rejects a conflicting standalone tag before a Gallery write or a GitHub mutation' { + $script:state.Tag = @{ + type = 'commit' + sha = '2222222222222222222222222222222222222222' + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'source|belongs|ownership' + } + + It 'rejects different release asset bytes before a Gallery write or a GitHub mutation' { + $script:state.Tag = @{ type = 'commit'; sha = $env:GITHUB_SHA } + $asset = @{ + name = 'TestModule-1.2.4.zip' + digest = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + } + $script:state.Release = @{ + id = 10 + tag_name = 'v1.2.4' + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false + assets = @($asset) + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'asset|contents|proof|ownership' + } + + It 'does not confuse GitHub API failure with an absent release or tag' { + $script:state.ApiError = 'gh: Service Unavailable (HTTP 503)' + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match '503|API|read|lookup' + } + + It 'rejects a Gallery-only package with no source and artifact proof' { + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'proof|ownership|unverifiable' + } + + It 'rejects a same-source tag without artifact and release-context proof' { + $script:state.Tag = @{ type = 'commit'; sha = $env:GITHUB_SHA } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'proof|ownership|unverifiable' + } + + It 'rejects an unverifiable annotated tag rather than trusting its release target hint' { + $script:state.Tag = @{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } + $script:state.TagObject = @{ + object = @{ type = 'tree'; sha = $env:GITHUB_SHA } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'commit|immutable|tag' + } + + It 'rejects a missing immutable source before all external mutations' { + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = '' + $env:GITHUB_SHA = '' + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'source|CommitSha|GITHUB_SHA' + } + + It 'keeps WhatIf free of Gallery and GitHub calls' { + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'true' + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $script:state.Calls.Count | Should -Be 0 + } +} From 2607da2d184688940622275a2d54f41985abcfd4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:10:12 +0100 Subject: [PATCH 02/21] Bind immutable publication claims to source and tested bytes --- .../src/Release-PSModule.Helpers.psm1 | 605 ++++++++++++++++++ .../Release-PSModule.Ownership.Tests.ps1 | 174 +++++ 2 files changed, 779 insertions(+) create mode 100644 .github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 create mode 100644 .github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 diff --git a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 new file mode 100644 index 00000000..550761ca --- /dev/null +++ b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 @@ -0,0 +1,605 @@ +$ErrorActionPreference = 'Stop' + +function Resolve-PSModuleReleaseTagCommit { + <# + .SYNOPSIS + Resolve a release tag to its exact immutable commit. + + .DESCRIPTION + Peels annotated tags and rejects unresolved or cyclic references. The + release's mutable target_commitish hint is not used as ownership proof. + + .EXAMPLE + Resolve-PSModuleReleaseTagCommit -Tag $tag -GetTagReference $readRef -GetTagObject $readTag + + Returns the commit referenced by the release tag. + + .INPUTS + None + + You can't pipe objects to Resolve-PSModuleReleaseTagCommit. + + .OUTPUTS + System.String + + The immutable commit SHA referenced by the tag. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The exact release tag name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Tag, + + # Returns the object referenced by the tag name. + [Parameter(Mandatory)] + [scriptblock] $GetTagReference, + + # Returns the object targeted by an annotated-tag SHA. + [Parameter(Mandatory)] + [scriptblock] $GetTagObject, + + # Allow only a verified absent tag returned by the lookup boundary. + [Parameter()] + [switch] $AllowMissing + ) + + $reference = & $GetTagReference $Tag + if ($null -eq $reference -and $AllowMissing) { + return + } + $visited = [System.Collections.Generic.HashSet[string]]::new() + while ($reference.type -eq 'tag') { + if ([string]::IsNullOrWhiteSpace($reference.sha) -or -not $visited.Add($reference.sha)) { + throw "Cannot determine release source: tag [$Tag] has invalid or cyclic tag data." + } + + $reference = & $GetTagObject $reference.sha + } + if ($reference.type -ne 'commit' -or [string]::IsNullOrWhiteSpace($reference.sha)) { + throw "Cannot determine the immutable commit for release tag [$Tag]." + } + [string]$reference.sha +} + +function Get-PSModulePrereleaseOwnership { + <# + .SYNOPSIS + Read the controller-owned prerelease provenance footer. + + .DESCRIPTION + Reads only the final versioned footer appended after authored notes. + Legacy records without that footer return no ownership evidence. + + .EXAMPLE + Get-PSModulePrereleaseOwnership -Body $release.body + + Returns the recorded repository, PR number, and immutable source. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePrereleaseOwnership. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + Validated prerelease ownership evidence, when present. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The complete published release body. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Body + ) + + $match = [regex]::Match($Body, '(?:^|\r?\n)\s*\z') + if (-not $match.Success) { + return + } + $bytes = [Convert]::FromBase64String($match.Groups[1].Value) + $ownership = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop + $number = 0L + if ([string]::IsNullOrWhiteSpace($ownership.Repository) -or + $ownership.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or + -not [long]::TryParse([string]$ownership.PullRequest, [ref]$number) -or + $number -lt 1 -or [string]::IsNullOrWhiteSpace($ownership.CommitSha)) { + throw 'The published prerelease ownership footer is invalid. Refusing to infer cleanup ownership.' + } + [pscustomobject]@{ + Repository = [string]$ownership.Repository + PullRequest = $number + CommitSha = [string]$ownership.CommitSha + } +} + +function Get-PSModuleReleaseOwnership { + <# + .SYNOPSIS + Verify a release footer against its repository, producing PR, and immutable source. + + .DESCRIPTION + Uses the canonical final-footer reader. Matching tag/source alone does not + authorize another pull request's release, and missing proof remains an error. + + .EXAMPLE + Get-PSModuleReleaseOwnership -Body $body -Repository $repository -PullRequest 42 -CommitSha $source + + Return the proven producing context or stop before a mutation. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleReleaseOwnership. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The verified release ownership. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The expected base repository. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Repository, + + # The expected producing pull request. + [Parameter(Mandatory)] + [ValidateRange(1, [long]::MaxValue)] + [long] $PullRequest, + + # The original build commit. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $CommitSha, + + # The complete existing release notes. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Body + ) + + $ownership = Get-PSModulePrereleaseOwnership -Body $Body + if ($null -eq $ownership) { + throw 'The existing release has no verifiable repository/pull request/source ownership footer.' + } + if ($ownership.Repository -ine $Repository -or $ownership.CommitSha -cne $CommitSha) { + throw 'The existing release ownership footer belongs to a different repository or immutable source.' + } + if ($ownership.PullRequest -ne $PullRequest) { + throw "The existing release belongs to pull request [$($ownership.PullRequest)], not [$PullRequest]." + } + $ownership +} + +function Get-PSModulePublicationClaim { + <# + .SYNOPSIS + Read the final versioned source-and-artifact claim from an annotated tag. + + .DESCRIPTION + The footer binds the repository, PR or direct-release context, resolved + coordinates, tested file tree, and exact ZIP bytes. Legacy tags provide no proof. + + .EXAMPLE + Get-PSModulePublicationClaim -Message $tag.message + + Return the validated claim without using numeric equality or mutable hints. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePublicationClaim. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The publication claim, when present. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The full annotated tag message. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $Message + ) + + $match = [regex]::Match($Message, '(?:^|\r?\n)\s*\z') + if (-not $match.Success) { + if ($Message -match 'PSModule\.Publication:') { + throw 'The publication claim footer is malformed or uses an unsupported proof version.' + } + return + } + $bytes = [Convert]::FromBase64String($match.Groups[1].Value) + $claim = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop + $number = 0L + if ($claim.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or + -not [long]::TryParse([string]$claim.PullRequest, [ref]$number) -or $number -lt 0 -or + $claim.CommitSha -notmatch '^[0-9a-f]{40}$' -or + $claim.ModuleName -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$' -or + $claim.GalleryVersion -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' -or + [string]::IsNullOrWhiteSpace($claim.ReleaseTag) -or + $claim.ReleaseTag -match '[\s\x00-\x1f\x7f]' -or + $claim.ReleaseTag -match '(^|/)\.|[~^:?*\[\\]|\.\.|@\{|//|/$|\.lock(?:/|$)|\.$' -or + $claim.ReleaseType -cnotin @('Release', 'Prerelease') -or + $claim.ArtifactSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or + $claim.AssetSha256 -notmatch '^[0-9A-Fa-f]{64}$' -or + $claim.AssetName -cne "$($claim.ModuleName)-$($claim.GalleryVersion).zip") { + throw 'The publication claim has invalid source, release context, coordinates, or artifact proof.' + } + $isPrerelease = $claim.GalleryVersion.Contains('-') + if (($claim.ReleaseType -ceq 'Prerelease') -ne $isPrerelease) { + throw 'The publication claim release type disagrees with its resolved module version.' + } + [pscustomobject]@{ + Repository = [string]$claim.Repository + PullRequest = $number + CommitSha = [string]$claim.CommitSha + ModuleName = [string]$claim.ModuleName + GalleryVersion = [string]$claim.GalleryVersion + ReleaseTag = [string]$claim.ReleaseTag + ReleaseType = [string]$claim.ReleaseType + ArtifactSha256 = ([string]$claim.ArtifactSha256).ToLowerInvariant() + AssetName = [string]$claim.AssetName + AssetSha256 = ([string]$claim.AssetSha256).ToLowerInvariant() + } +} + +function Get-PSModuleArtifactFile { + <# + .SYNOPSIS + Enumerate the tested module files without following links. + + .EXAMPLE + Get-PSModuleArtifactFile -Path $modulePath + + Return each file's exact relative path and full local path. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleArtifactFile. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The module file records. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The absolute tested module directory. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $directory = Get-Item -LiteralPath $Path -ErrorAction Stop + if (-not $directory.PSIsContainer -or $directory.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw 'The tested module root is not a verifiable directory or contains a link.' + } + $root = $directory.FullName + $items = @(Get-ChildItem -LiteralPath $root -Recurse -Force -ErrorAction Stop) + if ($items | Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) { + throw 'The tested artifact contains a link. Refusing to infer the published file tree.' + } + $files = @($items | Where-Object { -not $_.PSIsContainer }) + if ($files.Count -eq 0) { + throw 'The tested module artifact contains no files.' + } + foreach ($file in $files) { + [pscustomobject]@{ + RelativePath = [IO.Path]::GetRelativePath($root, $file.FullName).Replace('\', '/') + FullName = $file.FullName + } + } +} + +function Get-PSModuleContentDigest { + <# + .SYNOPSIS + Hash exact file paths, lengths, and SHA-256 content hashes. + + .DESCRIPTION + Length-prefixed records are sorted ordinally. Archive timestamps and generated + NuGet metadata are not part of the tested file-tree identity. + + .EXAMPLE + Get-PSModuleContentDigest -File $files + + Return the deterministic file-tree SHA-256. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleContentDigest. + + .OUTPUTS + System.String + + The lowercase SHA-256 of the file records. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # Exact relative paths, byte lengths, and raw SHA-256 hashes. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [object[]] $File + ) + + $records = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + $portablePaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($item in $File) { + $relativePath = [string]$item.RelativePath + if ([string]::IsNullOrWhiteSpace($relativePath) -or $relativePath -match '[\\\x00-\x1f\x7f:]' -or + $relativePath.StartsWith('/') -or $relativePath.Split('/') -contains '..' -or + $relativePath.Split('/') -contains '.' -or $relativePath.Split('/') -contains '' -or + -not $portablePaths.Add($relativePath) -or $item.Hash.Length -ne 32 -or $item.Length -lt 0) { + throw "The artifact has an invalid or ambiguous file path/content record [$relativePath]." + } + $records.Add($relativePath, $item) + } + $paths = [Collections.Generic.List[string]]::new($records.Keys) + $paths.Sort([StringComparer]::Ordinal) + $stream = [IO.MemoryStream]::new() + $writer = [IO.BinaryWriter]::new($stream, [Text.UTF8Encoding]::new($false), $true) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $writer.Write([int]$paths.Count) + foreach ($relativePath in $paths) { + $item = $records[$relativePath] + $writer.Write($relativePath) + $writer.Write([long]$item.Length) + $writer.Write([byte[]]$item.Hash) + } + $writer.Flush() + $stream.Position = 0 + [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + } finally { + $sha256.Dispose() + $writer.Dispose() + $stream.Dispose() + } +} + +function Get-PSModuleArtifactDigest { + <# + .SYNOPSIS + Compute the tested module's exact file-tree identity. + + .EXAMPLE + Get-PSModuleArtifactDigest -Path $modulePath + + Return a digest without modifying or importing module files. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleArtifactDigest. + + .OUTPUTS + System.String + + The lowercase tested file-tree SHA-256. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The tested module directory. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $records = [Collections.Generic.List[object]]::new() + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + foreach ($file in Get-PSModuleArtifactFile -Path $Path) { + $stream = [IO.File]::OpenRead($file.FullName) + try { + $records.Add([pscustomobject]@{ + RelativePath = $file.RelativePath + Length = $stream.Length + Hash = $sha256.ComputeHash($stream) + }) + } finally { + $stream.Dispose() + } + } + } finally { + $sha256.Dispose() + } + Get-PSModuleContentDigest -File $records.ToArray() +} + +function New-PSModuleReleaseArchive { + <# + .SYNOPSIS + Package the tested module into repeatable ZIP bytes. + + .DESCRIPTION + Uses ordinal file order, fixed ZIP timestamps, and no host-specific attributes. + Retries of the same tested files therefore compare exact published asset bytes. + + .EXAMPLE + New-PSModuleReleaseArchive -Path $modulePath -DestinationPath $zipPath + + Create the local release asset without modifying the module. + + .INPUTS + None + + You can't pipe objects to New-PSModuleReleaseArchive. + + .OUTPUTS + System.IO.FileInfo + + The newly created ZIP. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([IO.FileInfo])] + param( + # The tested module directory. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # A new absolute ZIP path in the controller's temporary directory. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $DestinationPath + ) + + $files = @(Get-PSModuleArtifactFile -Path $Path) + $records = [Collections.Generic.Dictionary[string, string]]::new([StringComparer]::Ordinal) + foreach ($file in $files) { + $records.Add($file.RelativePath, $file.FullName) + } + $paths = [Collections.Generic.List[string]]::new($records.Keys) + $paths.Sort([StringComparer]::Ordinal) + if (-not $PSCmdlet.ShouldProcess($DestinationPath, 'Create tested module ZIP')) { + return + } + $name = (Get-Item -LiteralPath $Path -ErrorAction Stop).Name + $stream = [IO.File]::Open($DestinationPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($relativePath in $paths) { + $entry = $archive.CreateEntry("$name/$relativePath", [IO.Compression.CompressionLevel]::Optimal) + $entry.LastWriteTime = [DateTimeOffset]::new(1980, 1, 1, 0, 0, 0, [TimeSpan]::Zero) + $entry.ExternalAttributes = 0 + $inputStream = [IO.File]::OpenRead($records[$relativePath]) + $outputStream = $entry.Open() + try { + $inputStream.CopyTo($outputStream) + } finally { + $outputStream.Dispose() + $inputStream.Dispose() + } + } + } finally { + $archive.Dispose() + $stream.Dispose() + } + [IO.FileInfo]::new($DestinationPath) +} + +function Get-PSModulePackageArtifactDigest { + <# + .SYNOPSIS + Verify an exact Gallery nupkg's identity and tested file-tree digest. + + .DESCRIPTION + Reads ZIP streams and a DTD-disabled nuspec without extracting or importing + package code. Only standard generated NuGet container metadata is excluded. + + .EXAMPLE + Get-PSModulePackageArtifactDigest -Path $package -ModuleName TestModule -GalleryVersion 1.2.4 + + Return the package payload digest after checking its name and exact version. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePackageArtifactDigest. + + .OUTPUTS + System.String + + The lowercase file-tree SHA-256 of the package payload. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The exact raw nupkg, never an installed module. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # The expected Gallery package name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName, + + # The resolved Gallery version, including a prerelease label when present. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $GalleryVersion + ) + + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $nuspecs = @($archive.Entries | Where-Object { $_.FullName -match '^[^/\\]+\.nuspec$' }) + if ($nuspecs.Count -ne 1) { + throw 'The Gallery package has missing or ambiguous nuspec identity metadata.' + } + $settings = [Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $stream = $nuspecs[0].Open() + $reader = [Xml.XmlReader]::Create($stream, $settings) + try { + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + $document.Load($reader) + } finally { + $reader.Dispose() + $stream.Dispose() + } + $metadata = $document.SelectSingleNode("/*[local-name()='package']/*[local-name()='metadata']") + if ($null -eq $metadata) { + throw 'The Gallery package is missing its nuspec identity metadata.' + } + $ids = @($metadata.SelectNodes("*[local-name()='id']")) + $versions = @($metadata.SelectNodes("*[local-name()='version']")) + if ($ids.Count -ne 1 -or $versions.Count -ne 1) { + throw 'The Gallery package has missing or ambiguous name/version identity metadata.' + } + $id = $ids[0].InnerText + $version = $versions[0].InnerText + if ($id -ine $ModuleName -or $version -cne $GalleryVersion) { + throw "The raw Gallery package identity [$id] [$version] does not match [$ModuleName] [$GalleryVersion]." + } + $records = [Collections.Generic.List[object]]::new() + foreach ($entry in $archive.Entries) { + $relativePath = $entry.FullName + if ($relativePath.EndsWith('/')) { + continue + } + if ($relativePath -ceq $nuspecs[0].FullName -or $relativePath -ceq '[Content_Types].xml' -or + $relativePath -ceq '_rels/.rels' -or $relativePath -ceq '.signature.p7s' -or + $relativePath -cmatch '^package/services/metadata/core-properties/[0-9a-f]+\.psmdcp$') { + continue + } + $stream = $entry.Open() + try { + $records.Add([pscustomobject]@{ + RelativePath = $relativePath + Length = $entry.Length + Hash = $sha256.ComputeHash($stream) + }) + } finally { + $stream.Dispose() + } + } + if ($records.Count -eq 0) { + throw 'The Gallery package has no verifiable module payload files.' + } + Get-PSModuleContentDigest -File $records.ToArray() + } finally { + $sha256.Dispose() + $archive.Dispose() + } +} diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 new file mode 100644 index 00000000..aa3ce778 --- /dev/null +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -0,0 +1,174 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester setup variables are used by test blocks.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeAll { + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force +} + +Describe 'Source-bound publication proof' { + BeforeEach { + $script:claim = [ordered]@{ + Repository = 'PSModule/TestModule' + PullRequest = 42 + CommitSha = '1111111111111111111111111111111111111111' + ModuleName = 'TestModule' + GalleryVersion = '1.2.4' + ReleaseTag = 'release-1.2.4' + ReleaseType = 'Release' + ArtifactSha256 = 'a' * 64 + AssetName = 'TestModule-1.2.4.zip' + AssetSha256 = 'b' * 64 + } + } + + It 'reads a final versioned claim without treating notes as identity' { + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + ($script:claim | ConvertTo-Json -Compress) + )) + + $result = Get-PSModulePublicationClaim -Message "Authored notes.`n" + + $result.Repository | Should -BeExactly 'PSModule/TestModule' + $result.PullRequest | Should -Be 42 + $result.CommitSha | Should -BeExactly $script:claim.CommitSha + $result.GalleryVersion | Should -BeExactly '1.2.4' + $result.ReleaseTag | Should -BeExactly 'release-1.2.4' + $result.ArtifactSha256 | Should -BeExactly ('a' * 64) + $result.AssetSha256 | Should -BeExactly ('b' * 64) + } + + It 'does not fabricate ownership for a legacy annotated tag' { + Get-PSModulePublicationClaim -Message 'Legacy tag message.' | Should -BeNullOrEmpty + } + + It 'rejects malformed claim provenance explicitly' -ForEach @( + @{ Message = '' } + @{ Message = '' } + @{ Message = '' } + ) { + { Get-PSModulePublicationClaim -Message $Message } | Should -Throw '*publication*' + } + + It 'rejects a foreign PR footer even when its immutable source matches' { + $owner = @{ + Repository = 'PSModule/TestModule' + PullRequest = 99 + CommitSha = $script:claim.CommitSha + } | ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $parameters = @{ + Body = "Notes.`n" + Repository = 'PSModule/TestModule' + PullRequest = 42 + CommitSha = $script:claim.CommitSha + } + + { Get-PSModuleReleaseOwnership @parameters } | Should -Throw '*pull request*' + } + + It 'rejects a foreign repository or source in the existing footer' -ForEach @( + @{ Repository = 'Other/TestModule'; CommitSha = '1111111111111111111111111111111111111111' } + @{ Repository = 'PSModule/TestModule'; CommitSha = '2222222222222222222222222222222222222222' } + ) { + $owner = @{ Repository = $Repository; PullRequest = 42; CommitSha = $CommitSha } | ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $parameters = @{ + Body = "" + Repository = 'PSModule/TestModule' + PullRequest = 42 + CommitSha = $script:claim.CommitSha + } + + { Get-PSModuleReleaseOwnership @parameters } | Should -Throw '*ownership*' + } +} + +Describe 'Tested artifact identity' { + BeforeEach { + $script:modulePath = Join-Path -Path $TestDrive -ChildPath 'TestModule' + $null = New-Item -Path $script:modulePath -ItemType Directory -Force + [IO.File]::WriteAllText( + (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'), + 'throw "Never import package bytes for proof."', + [Text.UTF8Encoding]::new($false) + ) + [IO.File]::WriteAllText( + (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psd1'), + "@{ ModuleVersion = '1.2.4'; PrivateData = @{ ConsumerMetadata = 'retained' } }", + [Text.UTF8Encoding]::new($false) + ) + } + + It 'produces identical ZIP bytes after timestamp-only changes and includes the exact files' { + $firstPath = Join-Path -Path $TestDrive -ChildPath 'first.zip' + $secondPath = Join-Path -Path $TestDrive -ChildPath 'second.zip' + $firstDigest = Get-PSModuleArtifactDigest -Path $script:modulePath + $null = New-PSModuleReleaseArchive -Path $script:modulePath -DestinationPath $firstPath + [IO.File]::SetLastWriteTimeUtc( + (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'), + [datetime]'2001-02-03T04:05:06Z' + ) + $null = New-PSModuleReleaseArchive -Path $script:modulePath -DestinationPath $secondPath + + (Get-FileHash -LiteralPath $firstPath).Hash | Should -BeExactly (Get-FileHash -LiteralPath $secondPath).Hash + Get-PSModuleArtifactDigest -Path $script:modulePath | Should -BeExactly $firstDigest + $archive = [IO.Compression.ZipFile]::OpenRead($firstPath) + try { + @($archive.Entries.FullName) | Should -Be @('TestModule/TestModule.psd1', 'TestModule/TestModule.psm1') + $archive.Entries[0].LastWriteTime.Year | Should -Be 1980 + } finally { + $archive.Dispose() + } + } + + It 'detects changed or renamed module files rather than comparing only the version' { + $original = Get-PSModuleArtifactDigest -Path $script:modulePath + [IO.File]::WriteAllText((Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'), 'Changed bytes.') + Get-PSModuleArtifactDigest -Path $script:modulePath | Should -Not -BeExactly $original + Rename-Item -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1') -NewName 'Other.psm1' + Get-PSModuleArtifactDigest -Path $script:modulePath | Should -Not -BeExactly $original + } + + It 'compares raw nupkg payload files without importing or changing consumer metadata' { + $packagePath = Join-Path -Path $TestDrive -ChildPath 'TestModule.1.2.4.nupkg' + $stream = [IO.File]::Create($packagePath) + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -LiteralPath $script:modulePath -File) { + $entry = $archive.CreateEntry($file.Name) + $entryStream = $entry.Open() + $fileStream = $file.OpenRead() + try { $fileStream.CopyTo($entryStream) } finally { $fileStream.Dispose(); $entryStream.Dispose() } + } + $entry = $archive.CreateEntry('TestModule.nuspec') + $writer = [IO.StreamWriter]::new($entry.Open()) + try { + $writer.Write('TestModule1.2.4') + } finally { + $writer.Dispose() + } + $entry = $archive.CreateEntry('[Content_Types].xml') + $writer = [IO.StreamWriter]::new($entry.Open()) + try { $writer.Write('') } finally { $writer.Dispose() } + } finally { + $archive.Dispose() + $stream.Dispose() + } + + $before = Get-PSModuleArtifactDigest -Path $script:modulePath + $parameters = @{ Path = $packagePath; ModuleName = 'TestModule'; GalleryVersion = '1.2.4' } + + Get-PSModulePackageArtifactDigest @parameters | Should -BeExactly $before + Get-PSModuleArtifactDigest -Path $script:modulePath | Should -BeExactly $before + $parameters.GalleryVersion = '1.2.5' + { Get-PSModulePackageArtifactDigest @parameters } | Should -Throw '*identity*' + } +} From 6b5ffcd7b059081f8d197468ebafac06f63a2680 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:10:12 +0100 Subject: [PATCH 03/21] Verify publication resources and create version claims atomically --- .../src/Publish-PSModule.Helpers.psm1 | 526 +++++++++++++++++- .../tests/Publish-PSModule.TestFixture.psm1 | 395 +++++++++++++ 2 files changed, 920 insertions(+), 1 deletion(-) create mode 100644 .github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index da6aa7f8..53e1cc9b 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -1,4 +1,10 @@ -function Get-ModuleVersionString { +$ErrorActionPreference = 'Stop' + +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force + +function Get-ModuleVersionString { <# .SYNOPSIS Builds the SemVer version string that identifies the module itself. @@ -43,3 +49,521 @@ "$ModuleVersion-$($Prerelease.Trim())" } + +function Invoke-PSModulePublicationApi { + <# + .SYNOPSIS + Read an explicit GitHub API status or perform a guarded create-only request. + + .DESCRIPTION + Only an observed HTTP 404 can represent absence. Authentication, API, empty, + and malformed-response errors remain failures, including during claim creation. + + .EXAMPLE + Invoke-PSModulePublicationApi -Endpoint $endpoint -AllowMissing + + Read a resource after repository/source access has been established. + + .INPUTS + None + + You can't pipe objects to Invoke-PSModulePublicationApi. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The HTTP status and parsed response value, when present. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([PSCustomObject])] + param( + # A repository-scoped REST endpoint. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Endpoint, + + # GET reads resources; POST creates a tag object or reference. + [Parameter()] + [ValidateSet('GET', 'POST')] + [string] $Method = 'GET', + + # Flat string fields passed as arguments, never evaluated as script. + [Parameter()] + [hashtable] $Field = @{}, + + # Permit only a verified absent-resource HTTP status. + [Parameter()] + [switch] $AllowMissing, + + # Expose a create-reference conflict for exact proof revalidation. + [Parameter()] + [switch] $AllowConflict + ) + + $arguments = @('api', $Endpoint, '--include', '--method', $Method) + foreach ($key in $Field.Keys) { + $arguments += @('--raw-field', "$key=$($Field[$key])") + } + if ($Method -ne 'GET' -and -not $PSCmdlet.ShouldProcess($Endpoint, 'Create publication claim')) { + return + } + $PSNativeCommandUseErrorActionPreference = $false + $response = gh @arguments 2>&1 + $exitCode = $LASTEXITCODE + $text = ($response | Out-String).Trim() + $status = [regex]::Match($text, '(?m)^HTTP/\S+\s+(\d{3})\b') + if (-not $status.Success) { + throw "Cannot verify the GitHub API status for [$Endpoint] (gh exited $exitCode): $text" + } + $statusCode = [int]$status.Groups[1].Value + if ($AllowMissing -and $Method -eq 'GET' -and $statusCode -eq 404 -and $exitCode -ne 0) { + return + } + $isConflict = $AllowConflict -and $Method -eq 'POST' -and $statusCode -in @(409, 422) -and $exitCode -ne 0 + if (-not $isConflict -and ($exitCode -ne 0 -or $statusCode -notin @(200, 201))) { + throw "GitHub API request [$Endpoint] failed (HTTP $statusCode, gh exited $exitCode): $text" + } + $parts = $text.Substring($status.Index) -split '\r?\n\r?\n', 2 + if ($parts.Count -ne 2 -or [string]::IsNullOrWhiteSpace($parts[1])) { + throw "The GitHub API response for [$Endpoint] has no verifiable JSON body." + } + $body = $parts[1] -replace '(?m)^gh:.*(?:\r?\n|\z)', '' + $value = $body | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $value) { + throw "The GitHub API response for [$Endpoint] returned no resource metadata." + } + [pscustomobject]@{ StatusCode = $statusCode; Value = $value } +} + +function Get-PSModuleGalleryPackage { + <# + .SYNOPSIS + Read only the exact resolved Gallery package. + + .EXAMPLE + Get-PSModuleGalleryPackage -Name TestModule -Version 1.2.4 + + Return the exact package or verified PackageNotFound absence. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleGalleryPackage. + + .OUTPUTS + System.Object + + The Gallery package metadata, when present. + #> + [CmdletBinding()] + [OutputType([object])] + param( + # The resolved module name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + # The exact Gallery version, including any prerelease label. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Version + ) + + $parameters = @{ + Name = $Name + Version = $Version + Repository = 'PSGallery' + ErrorAction = 'Stop' + } + if ($Version.Contains('-')) { + $parameters.Prerelease = $true + } + try { + $packages = @(Find-PSResource @parameters) + } catch { + $failure = $_ + if ($failure.FullyQualifiedErrorId -notlike 'PackageNotFound,*') { + throw + } + Write-Verbose "The exact Gallery package [$Name] [$Version] is absent." + return + } + if ($packages.Count -ne 1) { + throw "The exact Gallery lookup for [$Name] [$Version] returned missing or ambiguous metadata." + } + $package = $packages[0] + $packageVersion = Get-ModuleVersionString -ModuleVersion ([string]$package.Version) -Prerelease $package.Prerelease + if ($package.Name -ine $Name -or $packageVersion -cne $Version) { + throw "The Gallery lookup returned [$($package.Name)] [$packageVersion], not [$Name] [$Version]." + } + $package +} + +function Get-PSModuleGalleryArtifactDigest { + <# + .SYNOPSIS + Download an exact raw nupkg solely to verify its tested file-tree identity. + + .DESCRIPTION + Downloads no dependencies, never installs or imports the package, and removes + the isolated raw package after reading its identity and payload streams. + + .EXAMPLE + Get-PSModuleGalleryArtifactDigest -Name TestModule -Version 1.2.4 + + Return the proven package payload digest or surface a download/identity error. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleGalleryArtifactDigest. + + .OUTPUTS + System.String + + The Gallery package file-tree SHA-256. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The exact Gallery package name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + # The exact Gallery version. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Version + ) + + $directory = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath "PSModulePackage-$([guid]::NewGuid())" + $null = New-Item -Path $directory -ItemType Directory -ErrorAction Stop + try { + $parameters = @{ + Name = $Name + Version = "[$Version]" + Repository = 'PSGallery' + Path = $directory + AsNupkg = $true + SkipDependencyCheck = $true + TrustRepository = $true + ErrorAction = 'Stop' + } + if ($Version.Contains('-')) { + $parameters.Prerelease = $true + } + $null = Save-PSResource @parameters + $files = @(Get-ChildItem -LiteralPath $directory -File -Force -ErrorAction Stop) + if ($files.Count -ne 1 -or $files[0].Extension -ine '.nupkg') { + throw "The exact package download for [$Name] [$Version] did not produce one raw nupkg." + } + Get-PSModulePackageArtifactDigest -Path $files[0].FullName -ModuleName $Name -GalleryVersion $Version + } finally { + foreach ($file in Get-ChildItem -LiteralPath $directory -File -Force -ErrorAction Stop) { + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + } + Remove-Item -LiteralPath $directory -Force -ErrorAction Stop + } +} + +function Get-PSModulePublicationReader { + <# + .SYNOPSIS + Bind source, tag, release, and exact-package readers to the consumer repository. + + .EXAMPLE + Get-PSModulePublicationReader -Repository PSModule/TestModule + + Return the injectable read boundaries used by preflight and recovery. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePublicationReader. + + .OUTPUTS + System.Collections.Hashtable + + The source-bound resource readers. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + # The base consumer repository, not the workflow provider. + [Parameter(Mandatory)] + [ValidatePattern('^[^/\s]+/[^/\s]+$')] + [string] $Repository, + + # Plan can inspect immutable claims without the publisher's write token. + [Parameter()] + [switch] $ReadOnly + ) + + Write-Verbose "Binding publication readers to [$Repository]." + $requiresWriteAccess = -not $ReadOnly + @{ + GetSource = { + param($CommitSha) + $repositoryMetadata = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository").Value + if ($repositoryMetadata.full_name -ine $Repository) { + throw "Cannot establish access to the exact consumer repository [$Repository]." + } + if ($requiresWriteAccess -and $repositoryMetadata.permissions.push -ne $true) { + throw "Cannot verify release visibility and write access in [$Repository]. Refusing to treat hidden releases as absent." + } + $commit = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/commits/$CommitSha").Value + if ($commit.sha -cne $CommitSha) { + throw "Cannot verify the original immutable source [$CommitSha] in [$Repository]." + } + [string]$commit.sha + }.GetNewClosure() + GetTagReference = { + param($Tag) + $encodedTag = [Uri]::EscapeDataString($Tag) + $response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/ref/tags/$encodedTag" -AllowMissing + if ($null -eq $response) { + return + } + if ($response.Value.ref -cne "refs/tags/$Tag" -or $null -eq $response.Value.object) { + throw "The tag lookup for [$Tag] did not return the exact tag reference." + } + $response.Value.object + }.GetNewClosure() + GetTagObject = { + param($Sha) + $response = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/tags/$Sha").Value + if ($response.sha -cne $Sha -or $null -eq $response.object -or + $response.PSObject.Properties.Name -notcontains 'message') { + throw "Cannot verify the annotated publication tag [$Sha]." + } + $response + }.GetNewClosure() + GetRelease = { + param($Tag) + $encodedTag = [Uri]::EscapeDataString($Tag) + (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/releases/tags/$encodedTag" -AllowMissing).Value + }.GetNewClosure() + GetPackage = { + param($Name, $Version) + Get-PSModuleGalleryPackage -Name $Name -Version $Version + } + GetPackageArtifactDigest = { + param($Name, $Version) + Get-PSModuleGalleryArtifactDigest -Name $Name -Version $Version + } + } +} + +function Get-PSModulePublicationState { + <# + .SYNOPSIS + Verify source, tag, release, and package ownership before any publication mutation. + + .DESCRIPTION + Uses the canonical immutable-tag and final-footer algorithms. The returned + snapshot is not a lease; callers atomically claim a new tag and recheck it. + + .EXAMPLE + Get-PSModulePublicationState -ExpectedClaim $claim -Reader $reader + + Return the proven exact publication state or fail without writing. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePublicationState. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The verified tag object, release, and package state. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The immutable source, resolved context, and tested artifact identity. + [Parameter(Mandatory)] + [PSCustomObject] $ExpectedClaim, + + # Injected read-only boundaries, including exact raw package verification. + [Parameter(Mandatory)] + [hashtable] $Reader + ) + + foreach ($name in @('GetSource', 'GetTagReference', 'GetTagObject', 'GetRelease', 'GetPackage', 'GetPackageArtifactDigest')) { + if ($Reader[$name] -isnot [scriptblock]) { + throw "The publication reader [$name] is missing." + } + } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($ExpectedClaim | ConvertTo-Json -Compress))) + $expected = Get-PSModulePublicationClaim -Message "" + $source = & $Reader.GetSource $expected.CommitSha + if ($source -cne $expected.CommitSha) { + throw "Cannot verify the original immutable publication source [$($expected.CommitSha)]." + } + $tagEvidence = @{ Reference = $null; Annotation = $null } + $tagParameters = @{ + Tag = $expected.ReleaseTag + AllowMissing = $true + GetTagReference = { + param($Tag) + $tagEvidence.Reference = & $Reader.GetTagReference $Tag + $tagEvidence.Reference + }.GetNewClosure() + GetTagObject = { + param($Sha) + $annotation = & $Reader.GetTagObject $Sha + if ($null -eq $tagEvidence.Annotation) { + $tagEvidence.Annotation = $annotation + } + $annotation.object + }.GetNewClosure() + } + $tagCommit = Resolve-PSModuleReleaseTagCommit @tagParameters + if ($null -ne $tagCommit -and $tagCommit -cne $expected.CommitSha) { + throw "Release tag [$($expected.ReleaseTag)] belongs to [$tagCommit], not source [$($expected.CommitSha)]." + } + $claim = Get-PSModulePublicationClaim -Message $tagEvidence.Annotation.message + $release = & $Reader.GetRelease $expected.ReleaseTag + $package = & $Reader.GetPackage $expected.ModuleName $expected.GalleryVersion + if ($null -eq $tagCommit) { + if ($null -ne $release -or $null -ne $package) { + throw "Existing release or Gallery version [$($expected.GalleryVersion)] has no verifiable source/artifact ownership proof." + } + } else { + if ($null -eq $claim -or $tagEvidence.Annotation.tag -cne $expected.ReleaseTag) { + throw "The existing tag [$($expected.ReleaseTag)] has no verifiable publication ownership proof." + } + if ($claim.Repository -ine $expected.Repository -or $claim.PullRequest -ne $expected.PullRequest) { + throw 'The publication claim belongs to a different repository or producing pull request.' + } + foreach ($name in @('CommitSha', 'ModuleName', 'GalleryVersion', 'ReleaseTag', 'ReleaseType', 'AssetName')) { + if ($claim.$name -cne $expected.$name) { + throw "The publication claim has conflicting [$name] ownership." + } + } + if ($claim.ArtifactSha256 -cne $expected.ArtifactSha256 -or $claim.AssetSha256 -cne $expected.AssetSha256) { + throw 'The claimed version belongs to different tested artifact or release asset contents.' + } + } + if ($null -ne $release) { + if ($release.tag_name -cne $expected.ReleaseTag -or + [string]::IsNullOrWhiteSpace($release.html_url) -or + $release.prerelease -isnot [bool] -or + $release.prerelease -ne ($expected.ReleaseType -ceq 'Prerelease') -or + $release.PSObject.Properties.Name -notcontains 'assets' -or $null -eq $release.assets) { + throw "Cannot verify the existing release context for [$($expected.ReleaseTag)]." + } + $owner = Get-PSModulePrereleaseOwnership -Body $release.body + if ($null -ne $owner -or ($expected.ReleaseType -ceq 'Prerelease' -and $expected.PullRequest -gt 0)) { + if ($expected.PullRequest -lt 1) { + throw 'The existing release ownership footer conflicts with the direct-release context.' + } + $null = Get-PSModuleReleaseOwnership -Body $release.body -Repository $expected.Repository ` + -PullRequest $expected.PullRequest -CommitSha $expected.CommitSha + } + $assets = @($release.assets) + if ($assets.Count -gt 1 -or ($assets.Count -eq 1 -and $assets[0].name -cne $expected.AssetName)) { + throw 'The existing release contains foreign or ambiguous module assets.' + } + if ($assets.Count -eq 1) { + $digest = [regex]::Match([string]$assets[0].digest, '^sha256:([0-9A-Fa-f]{64})$') + if (-not $digest.Success -or $assets[0].state -cne 'uploaded') { + throw "The existing asset [$($expected.AssetName)] has no verifiable published-byte proof." + } + if ($digest.Groups[1].Value.ToLowerInvariant() -cne $expected.AssetSha256) { + throw "The existing asset [$($expected.AssetName)] contains different published bytes." + } + } + } + if ($null -ne $package) { + $packageDigest = & $Reader.GetPackageArtifactDigest $expected.ModuleName $expected.GalleryVersion + if ($packageDigest -cne $expected.ArtifactSha256) { + throw "The Gallery package [$($expected.GalleryVersion)] has different or unverifiable tested artifact contents." + } + } + [pscustomobject]@{ + TagSha = $tagEvidence.Reference.sha + Claim = $claim + Release = $release + Package = $package + } +} + +function New-PSModulePublicationTag { + <# + .SYNOPSIS + Atomically claim an absent version tag for the verified tested artifact. + + .DESCRIPTION + Creates an annotated object and a create-only ref. Never updates an existing + tag. A competing ref is accepted only after proving identical source/context/bytes. + + .EXAMPLE + New-PSModulePublicationTag -Claim $claim -State $state -Reader $reader + + Return the claimed and reverified tag state before Gallery publication. + + .INPUTS + None + + You can't pipe objects to New-PSModulePublicationTag. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The verified immutable publication claim. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([PSCustomObject])] + param( + # The verified resolved source, release context, and artifact claim. + [Parameter(Mandatory)] + [PSCustomObject] $Claim, + + # The preceding read-only preflight snapshot. + [Parameter(Mandatory)] + [PSCustomObject] $State, + + # The same read boundaries used to verify concurrent claims. + [Parameter(Mandatory)] + [hashtable] $Reader + ) + + $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($null -ne $current.TagSha) { + if ($null -ne $State.TagSha -and $current.TagSha -cne $State.TagSha) { + throw 'The existing publication tag changed after preflight. Refusing Gallery publication.' + } + return $current + } + if (-not $PSCmdlet.ShouldProcess("$($Claim.Repository):$($Claim.ReleaseTag)", 'Create source-bound annotated version tag')) { + return + } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($Claim | ConvertTo-Json -Compress))) + $message = "" + $tagFields = @{ + tag = $Claim.ReleaseTag + message = $message + object = $Claim.CommitSha + type = 'commit' + } + $tag = (Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/tags" ` + -Method POST -Field $tagFields).Value + if ($tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -cne $Claim.ReleaseTag -or + $tag.object.type -cne 'commit' -or $tag.object.sha -cne $Claim.CommitSha -or $tag.message -cne $message) { + throw 'The create-only annotated tag response did not preserve the exact publication claim.' + } + $reference = Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/refs" ` + -Method POST -Field @{ ref = "refs/tags/$($Claim.ReleaseTag)"; sha = $tag.sha } -AllowConflict + if ($reference.StatusCode -ne 201 -and $reference.Value.message -cne 'Reference already exists') { + throw "Cannot atomically claim release tag [$($Claim.ReleaseTag)] (HTTP $($reference.StatusCode))." + } + $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($null -eq $current.TagSha -or + ($reference.StatusCode -eq 201 -and $current.TagSha -cne $tag.sha)) { + throw 'The publication tag changed during the create-only claim. Refusing Gallery publication.' + } + $current +} diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 new file mode 100644 index 00000000..2c5bce0c --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 @@ -0,0 +1,395 @@ +$ErrorActionPreference = 'Stop' + +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force + +function New-PSModuleFixturePackage { + <# + .SYNOPSIS + Create an anonymous raw nupkg from local fixture files without running a publisher. + + .EXAMPLE + New-PSModuleFixturePackage -Path $module -DestinationPath $package -Version 1.2.4 + + Package the local test payload and independent NuGet identity metadata. + + .INPUTS + None + + You can't pipe objects to New-PSModuleFixturePackage. + + .OUTPUTS + None + + This test helper creates only the named local nupkg. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The local module fixture. + [Parameter(Mandatory)] + [string] $Path, + + # The named local nupkg destination. + [Parameter(Mandatory)] + [string] $DestinationPath, + + # The exact fixture package version. + [Parameter(Mandatory)] + [string] $Version + ) + + if (-not $PSCmdlet.ShouldProcess($DestinationPath, 'Create local raw package fixture')) { + return + } + $name = (Get-Item -LiteralPath $Path).Name + $stream = [IO.File]::Create($DestinationPath) + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($file in Get-ChildItem -LiteralPath $Path -File -Recurse -Force) { + $relative = [IO.Path]::GetRelativePath($Path, $file.FullName).Replace('\', '/') + $entryStream = $archive.CreateEntry($relative).Open() + $fileStream = $file.OpenRead() + try { + $fileStream.CopyTo($entryStream) + } finally { + $fileStream.Dispose() + $entryStream.Dispose() + } + } + $writer = [IO.StreamWriter]::new($archive.CreateEntry("$name.nuspec").Open()) + try { + $writer.Write("$name$Version") + } finally { + $writer.Dispose() + } + $writer = [IO.StreamWriter]::new($archive.CreateEntry('[Content_Types].xml').Open()) + try { + $writer.Write('') + } finally { + $writer.Dispose() + } + } finally { + $archive.Dispose() + $stream.Dispose() + } +} + +function Set-PSModuleFixtureClaim { + <# + .SYNOPSIS + Set the exact annotated claim returned by the isolated GitHub reader. + + .EXAMPLE + Set-PSModuleFixtureClaim -State $state -Claim $state.ExpectedClaim + + Arrange an existing exact-source tag, including its byte/context proof. + + .INPUTS + None + + You can't pipe objects to Set-PSModuleFixtureClaim. + + .OUTPUTS + None + + This helper changes only the in-memory test state. + #> + [CmdletBinding(SupportsShouldProcess)] + param( + # The isolated command state. + [Parameter(Mandatory)] + [hashtable] $State, + + # The desired source/context/artifact claim. + [Parameter(Mandatory)] + [object] $Claim, + + # The immutable annotation object identity used by the fixture. + [Parameter()] + [string] $TagSha = '3333333333333333333333333333333333333333' + ) + + if (-not $PSCmdlet.ShouldProcess('Publication fixture', 'Set local annotated claim')) { + return + } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($Claim | ConvertTo-Json -Compress))) + $State.Tag = @{ type = 'tag'; sha = $TagSha } + $State.TagObject = @{ + sha = $TagSha + tag = $Claim.ReleaseTag + message = "" + object = @{ type = 'commit'; sha = $Claim.CommitSha } + } +} + +function New-PSModulePublicationFixture { + <# + .SYNOPSIS + Replace all publication read/write boundaries with isolated local state. + + .DESCRIPTION + The fake API follows HTTP status, create-only refs, annotated tags, release + metadata, and no-clobber uploads. Gallery packages are raw local nupkg fixtures. + + .EXAMPLE + New-PSModulePublicationFixture -Path $module -Version 1.2.4 -Tag v1.2.4 + + Return the shared write-spy state after replacing the external commands. + + .INPUTS + None + + You can't pipe objects to New-PSModulePublicationFixture. + + .OUTPUTS + System.Collections.Hashtable + + The mutable local fixture state and exact expected claim. + #> + [CmdletBinding(SupportsShouldProcess)] + [OutputType([hashtable])] + param( + # The already-created local tested module. + [Parameter(Mandatory)] + [string] $Path, + + # The exact resolved fixture Gallery version. + [Parameter(Mandatory)] + [string] $Version, + + # The exact resolved fixture GitHub tag. + [Parameter(Mandatory)] + [string] $Tag, + + # The producing PR, or zero for a direct stable release. + [Parameter()] + [long] $PullRequest = 0, + + # The immutable build source used by the anonymous fixture. + [Parameter()] + [string] $CommitSha = '1111111111111111111111111111111111111111' + ) + + if (-not $PSCmdlet.ShouldProcess($Path, 'Install local publication command replacements')) { + return + } + $name = (Get-Item -LiteralPath $Path).Name + $zipPath = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath "$([guid]::NewGuid()).zip" + try { + $null = New-PSModuleReleaseArchive -Path $Path -DestinationPath $zipPath + $assetDigest = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + } finally { + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + } + $state = @{ + Calls = [Collections.Generic.List[string]]::new() + GalleryWrites = 0 + GitHubWrites = 0 + GitHubCreationUpload = 0 + Tag = $null + TagObject = $null + NewTagObject = $null + Release = $null + Package = $null + RawPackage = '' + ApiError = '' + TagReads = 0 + OnTagRead = $null + OnTagCreate = $null + FailReleaseCreate = $false + ExpectedClaim = [pscustomobject]@{ + Repository = "PSModule/$name" + PullRequest = $PullRequest + CommitSha = $CommitSha + ModuleName = $name + GalleryVersion = $Version + ReleaseTag = $Tag + ReleaseType = if ($Version.Contains('-')) { 'Prerelease' } else { 'Release' } + ArtifactSha256 = Get-PSModuleArtifactDigest -Path $Path + AssetName = "$name-$Version.zip" + AssetSha256 = $assetDigest + } + } + Set-Item -Path function:global:Find-PSResource -Value { + [CmdletBinding()] + param( + [string] $Name, + [string] $Version, + [string] $Repository, + [switch] $Prerelease + ) + if ($Name -ine $state.ExpectedClaim.ModuleName -or + $Version -cne $state.ExpectedClaim.GalleryVersion -or $Repository -cne 'PSGallery' -or + $Prerelease.IsPresent -ne ($state.ExpectedClaim.ReleaseType -ceq 'Prerelease')) { + throw "Unexpected exact Gallery lookup: [$Name] [$Version] [$Repository]." + } + $state.Calls.Add('gallery-read') + if ($null -ne $state.Package) { + return $state.Package + } + Write-Error -Message 'The exact fixture package is absent.' -ErrorId PackageNotFound -Category ObjectNotFound + }.GetNewClosure() + Set-Item -Path function:global:Publish-PSResource -Value { + $state.Calls.Add('gallery-write') + $state.GalleryWrites++ + }.GetNewClosure() + Set-Item -Path function:global:Save-PSResource -Value { + [CmdletBinding()] + param( + [string] $Name, + [string] $Version, + [string] $Repository, + [string] $Path, + [switch] $AsNupkg, + [switch] $SkipDependencyCheck, + [switch] $TrustRepository, + [switch] $Prerelease + ) + if ($Name -ine $state.ExpectedClaim.ModuleName -or $Version -cne "[$($state.ExpectedClaim.GalleryVersion)]" -or + $Repository -cne 'PSGallery' -or -not $AsNupkg -or -not $SkipDependencyCheck -or -not $TrustRepository -or + $Prerelease.IsPresent -ne ($state.ExpectedClaim.ReleaseType -ceq 'Prerelease') -or + -not (Test-Path -LiteralPath $state.RawPackage -PathType Leaf)) { + throw 'The exact raw package fixture is unavailable; never download or import real packages for proof.' + } + Copy-Item -LiteralPath $state.RawPackage -Destination (Join-Path -Path $Path -ChildPath 'fixture.nupkg') + $state.Calls.Add('raw-package-read') + }.GetNewClosure() + Set-Item -Path function:global:Invoke-WebRequest -Value { + throw 'Unexpected network access in an isolated publication fixture.' + } + Set-Item -Path function:global:gh -Value { + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + $state.Calls.Add("github: $($Arguments -join ' ')") + $global:LASTEXITCODE = 0 + $claim = $state.ExpectedClaim + if ($Arguments[0] -ceq 'api') { + if ($state.ApiError) { + $global:LASTEXITCODE = 1 + return $state.ApiError + } + $endpoint = $Arguments[1] + $status = 200 + $value = $null + if ($Arguments -contains 'POST') { + $state.GitHubWrites++ + $fields = @{} + for ($index = 0; $index -lt $Arguments.Count - 1; $index++) { + if ($Arguments[$index] -ceq '--raw-field' -or $Arguments[$index] -ceq '--field') { + $pair = $Arguments[$index + 1].Split('=', 2) + $fields[$pair[0]] = $pair[1] + } + } + if ($endpoint -ceq "repos/$($claim.Repository)/git/tags") { + $state.Calls.Add('tag-object-create') + $value = @{ + sha = '3333333333333333333333333333333333333333' + tag = $fields.tag + message = $fields.message + object = @{ type = $fields.type; sha = $fields.object } + } + $state.NewTagObject = $value + $status = 201 + } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/refs") { + $state.Calls.Add('tag-ref-create') + if ($null -ne $state.OnTagCreate) { + & $state.OnTagCreate $state + } + if ($null -ne $state.Tag) { + $status = 422 + $global:LASTEXITCODE = 1 + $value = @{ message = 'Reference already exists' } + } else { + $state.Tag = @{ type = 'tag'; sha = $fields.sha } + $state.TagObject = $state.NewTagObject + $value = @{ ref = $fields.ref; object = $state.Tag } + $status = 201 + } + } elseif ($endpoint -ceq "repos/$($claim.Repository)/releases/generate-notes") { + $value = @{ body = 'Complete independently generated fixture notes.' } + } else { + throw "Unexpected create API in the publication fixture: [$endpoint]." + } + } elseif ($endpoint -ceq "repos/$($claim.Repository)") { + $value = @{ full_name = $claim.Repository; permissions = @{ push = $true } } + } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/commits/$($claim.CommitSha)") { + $value = @{ sha = $claim.CommitSha } + } elseif ($endpoint -like '*/git/ref/tags/*') { + $state.TagReads++ + if ($null -ne $state.OnTagRead) { + & $state.OnTagRead $state + } + if ($null -ne $state.Tag) { + $value = @{ ref = "refs/tags/$($claim.ReleaseTag)"; object = $state.Tag } + } + } elseif ($endpoint -like '*/git/tags/*') { + $value = $state.TagObject + } elseif ($endpoint -like '*/releases/tags/*') { + $value = $state.Release + } else { + throw "Unexpected read API in the publication fixture: [$endpoint]." + } + if ($null -eq $value) { + $status = 404 + $global:LASTEXITCODE = 1 + $value = @{ message = 'Not Found' } + } + $json = $value | ConvertTo-Json -Depth 20 -Compress + if ($Arguments -contains '--include') { + return "HTTP/2.0 $status Fixture`n`n$json" + } + return $json + } + if ($Arguments[0] -ceq 'release' -and $Arguments[1] -ceq 'create') { + $state.GitHubWrites++ + $state.GitHubCreationUpload++ + $state.Calls.Add('release-create') + if ($state.FailReleaseCreate) { + $global:LASTEXITCODE = 1 + return 'Injected GitHub release creation failure.' + } + $notes = '' + $notesIndex = [array]::IndexOf($Arguments, '--notes-file') + if ($notesIndex -ge 0) { + $notes = [IO.File]::ReadAllText($Arguments[$notesIndex + 1]) + } + $state.Release = [pscustomobject]@{ + id = 10 + tag_name = $claim.ReleaseTag + html_url = "https://github.com/$($claim.Repository)/releases/tag/$($claim.ReleaseTag)" + prerelease = $claim.ReleaseType -ceq 'Prerelease' + draft = $false + body = $notes + assets = @() + } + return $state.Release.html_url + } + if ($Arguments[0] -ceq 'release' -and $Arguments[1] -ceq 'upload') { + $state.GitHubWrites++ + $state.GitHubCreationUpload++ + $state.Calls.Add('asset-upload') + if ($Arguments -contains '--clobber' -or $null -eq $state.Release -or @($state.Release.assets).Count -gt 0) { + throw 'The fixture refuses missing-release uploads and replacement of published bytes.' + } + $path = $Arguments[3] + $state.Release.assets = @([pscustomobject]@{ + name = [IO.Path]::GetFileName($path) + state = 'uploaded' + digest = "sha256:$((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant())" + }) + return + } + if ($Arguments[0] -ceq 'pr' -and $Arguments[1] -ceq 'comment') { + $state.Calls.Add('pr-comment') + return + } + throw "Unexpected command in the isolated publication fixture: $($Arguments -join ' ')." + }.GetNewClosure() + $state +} From ee65fa7b8742ae51a9060d49a4e5cbae09d595b0 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:10:12 +0100 Subject: [PATCH 04/21] Preflight Gallery writes and preserve release-side ownership rechecks --- .github/actions/Publish-PSModule/action.yml | 9 + .../actions/Publish-PSModule/src/publish.ps1 | 129 +++++++---- .../Publish-PSModule.Preflight.Tests.ps1 | 198 ++++++++++------ .../tests/Publish-PSModule.Recovery.Tests.ps1 | 62 ++--- .../actions/Release-PSModule/src/release.ps1 | 219 +++++++++++------- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 142 ++++++++++-- .github/workflows/Publish-Module.yml | 2 + 7 files changed, 511 insertions(+), 250 deletions(-) diff --git a/.github/actions/Publish-PSModule/action.yml b/.github/actions/Publish-PSModule/action.yml index d21604d1..8e7c3d1d 100644 --- a/.github/actions/Publish-PSModule/action.yml +++ b/.github/actions/Publish-PSModule/action.yml @@ -17,6 +17,13 @@ inputs: description: Normalized pull request context JSON from Get-PSModuleSettings. required: false default: '' + ReleaseTag: + description: Exact GitHub tag already resolved for the tested module artifact. + required: true + CommitSha: + description: Immutable commit that produced the tested module artifact; defaults to the original workflow SHA. + required: false + default: '' WhatIf: description: If specified, the action will only log the changes it would make, but will not publish the module. required: false @@ -55,5 +62,7 @@ runs: PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath: ${{ inputs.ModulePath }} PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY: ${{ inputs.PSGALLERY_API_KEY }} PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest: ${{ inputs.PullRequest }} + PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag: ${{ inputs.ReleaseTag }} + PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha: ${{ inputs.CommitSha || github.sha }} PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf: ${{ inputs.WhatIf }} run: ${{ github.action_path }}/src/publish.ps1 diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 51095015..7543fc93 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -2,13 +2,21 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'psGalleryApiKey', Justification = 'Variable is used in script blocks.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'publicationState', + Justification = 'The verified state is shared across logging script blocks.' +)] [CmdletBinding()] param() $PSStyle.OutputRendering = 'Ansi' +$ErrorActionPreference = 'Stop' Import-Module -Name 'PSModule' -Force Import-Module -Name "$PSScriptRoot/Publish-PSModule.Helpers.psm1" -Force +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force #region Load inputs LogGroup 'Load inputs' { @@ -36,6 +44,17 @@ LogGroup 'Load inputs' { $modulePath = Resolve-Path -Path $modulePathCandidate | Select-Object -ExpandProperty Path $psGalleryApiKey = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY $whatIf = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf -eq 'true' + $releaseTag = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag + $sourceCommit = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha + if ([string]::IsNullOrWhiteSpace($sourceCommit)) { + $sourceCommit = $env:GITHUB_SHA + } + if ([string]::IsNullOrWhiteSpace($releaseTag)) { + throw 'ReleaseTag is required. Pass the already-resolved Publish.Module.Resolution.FullVersion.' + } + if ([string]::IsNullOrWhiteSpace($sourceCommit)) { + throw 'CommitSha or the original GITHUB_SHA is required to establish the tested artifact source.' + } Write-Host "Module name: [$name]" Write-Host "Module path: [$modulePath]" @@ -130,58 +149,80 @@ LogGroup 'Resolve version from manifest' { } #endregion Resolve version from manifest -#region Install module dependencies -LogGroup 'Install module dependencies' { - Resolve-PSModuleDependency -ManifestFilePath $manifestFilePath -} -#endregion Install module dependencies +$zipDirectory = $null +$zipPath = $null +try { + LogGroup 'Source-bound publication preflight' { + if ($whatIf) { + Write-Host "WhatIf: Verify source [$sourceCommit], tag [$releaseTag], and the tested module before publication." + } else { + $zipDirectory = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath "PSModulePreflight-$([guid]::NewGuid())" + $null = New-Item -Path $zipDirectory -ItemType Directory + $zipFileName = "$name-$publishPSVersion.zip" + $zipPath = Join-Path -Path $zipDirectory -ChildPath $zipFileName + $artifactDigest = Get-PSModuleArtifactDigest -Path $modulePath + $null = New-PSModuleReleaseArchive -Path $modulePath -DestinationPath $zipPath + $claim = [pscustomobject]@{ + Repository = $env:GITHUB_REPOSITORY + PullRequest = if ($null -eq $prNumber) { 0 } else { $prNumber } + CommitSha = $sourceCommit + ModuleName = $name + GalleryVersion = $publishPSVersion + ReleaseTag = $releaseTag + ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } + ArtifactSha256 = $artifactDigest + AssetName = $zipFileName + AssetSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + $publicationReader = Get-PSModulePublicationReader -Repository $env:GITHUB_REPOSITORY + $publicationState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + } + } -#region Publish to PSGallery -LogGroup 'Publish to PSGallery' { - $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } - $psGalleryReleaseLink = "https://www.powershellgallery.com/packages/$name/$publishPSVersion" + LogGroup 'Install module dependencies' { + Resolve-PSModuleDependency -ManifestFilePath $manifestFilePath + } - Write-Host 'Publish module to PowerShell Gallery using API key from environment.' - if ($whatIf) { - Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***" - } else { - # A version that is not on the Gallery is the expected state for a new release, but PSResourceGet - # reports it as a PackageNotFound error, which -ErrorAction Stop turns into a throw. Only that error - # may be treated as 'not published'; any other failure (for example a Gallery outage) must stay fatal, - # otherwise a version that already exists would be re-published and fail the upload with a conflict. - $publishedPackage = $null - try { - $publishedPackage = Find-PSResource -Name $name -Version $publishPSVersion -Repository PSGallery -ErrorAction Stop - } catch { - if ($_.FullyQualifiedErrorId -notlike 'PackageNotFound,*') { throw } - Write-Host "$name $publishPSVersion is not on the PowerShell Gallery yet." - } + LogGroup 'Publish to PSGallery' { + $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } + $psGalleryReleaseLink = "https://www.powershellgallery.com/packages/$name/$publishPSVersion" - if ($publishedPackage) { - Write-Host ( - "::notice title=♻️ Resuming Gallery-only publication::$name $publishPSVersion is already " + - 'published to the PowerShell Gallery.' - ) + if ($whatIf) { + Write-Host "WhatIf: Create-only annotated claim for [$releaseTag]; retain any existing published bytes." + Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***" } else { - Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey $psGalleryApiKey + $publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader + $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + if ($currentState.TagSha -cne $publicationState.TagSha -or + (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { + throw 'The publication claim or tested artifact changed before the Gallery write. Refusing publication.' + } + if ($null -ne $currentState.Package) { + Write-Host "Retaining the proven identical Gallery package [$name] [$publishPSVersion]." + } else { + Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey $psGalleryApiKey -ErrorAction Stop + } } - } - if ($whatIf -and $prNumber) { - Write-Host ( - "gh pr comment $prNumber -b " + - "'✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)'" - ) - } elseif (-not $whatIf -and $prNumber) { - Write-Host "::notice title=✅ $releaseType`: PowerShell Gallery - $name $publishPSVersion::$psGalleryReleaseLink" - gh pr comment $prNumber -b "✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)" - if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to comment on the pull request.' - exit $LASTEXITCODE + if ($whatIf -and $prNumber) { + Write-Host ( + "gh pr comment $prNumber -b " + + "'✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)'" + ) + } elseif (-not $whatIf -and $prNumber) { + gh pr comment $prNumber -b "✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)" + if ($LASTEXITCODE -ne 0) { + throw 'Failed to comment on the pull request.' + } } - } else { Write-Host "::notice title=✅ $releaseType`: PowerShell Gallery - $name $publishPSVersion::$psGalleryReleaseLink" } +} finally { + if ($zipPath -and (Test-Path -LiteralPath $zipPath)) { + Remove-Item -LiteralPath $zipPath -Force + } + if ($zipDirectory -and (Test-Path -LiteralPath $zipDirectory)) { + Remove-Item -LiteralPath $zipDirectory -Force + } } -#endregion Publish to PSGallery Write-Host "Gallery publishing complete. Version: [$publishPSVersion]" diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 4ff067bc..6a071a97 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -13,6 +13,7 @@ BeforeAll { throw 'Cannot acquire the process environment lock for action tests.' } Import-Module -Name 'PSModule' -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Publish-PSModule.TestFixture.psm1') -Force $script:publishScriptPath = [IO.Path]::GetFullPath( [IO.Path]::Combine($PSScriptRoot, '..', 'src', 'publish.ps1') @@ -35,7 +36,7 @@ BeforeAll { foreach ($name in $script:environmentVariableNames) { $script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) } - $script:replacedCommands = @('gh', 'Find-PSResource', 'Publish-PSResource', 'Invoke-WebRequest') + $script:replacedCommands = @('gh', 'Find-PSResource', 'Publish-PSResource', 'Invoke-WebRequest', 'Save-PSResource') $script:originalFunctions = @{} foreach ($name in $script:replacedCommands) { $function = Get-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue @@ -94,76 +95,7 @@ Describe 'Publish-PSModule source-bound preflight' { $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' - $state = @{ - Calls = [Collections.Generic.List[string]]::new() - GalleryWrites = 0 - GitHubWrites = 0 - Tag = $null - TagObject = $null - Release = $null - Package = $null - ApiError = '' - } - $script:state = $state - - Set-Item -Path function:global:Find-PSResource -Value { - [CmdletBinding()] - param( - [string] $Name, - [string] $Version, - [string] $Repository - ) - if ($Name -cne 'TestModule' -or $Version -cne '1.2.4' -or $Repository -cne 'PSGallery') { - throw "Unexpected exact package lookup: [$Name] [$Version] [$Repository]." - } - $state.Calls.Add('gallery-read') - if ($null -ne $state.Package) { - return $state.Package - } - Write-Error -Message 'The exact fixture package is absent.' -ErrorId PackageNotFound -Category ObjectNotFound - }.GetNewClosure() - - Set-Item -Path function:global:Publish-PSResource -Value { - $state.Calls.Add('gallery-write') - $state.GalleryWrites++ - }.GetNewClosure() - - Set-Item -Path function:global:Invoke-WebRequest -Value { - throw 'Package bytes must come from an explicitly injected local fixture, never a network request.' - } - - Set-Item -Path function:global:gh -Value { - [CmdletBinding()] - param( - [Parameter(ValueFromRemainingArguments)] - [string[]] $Arguments - ) - $state.Calls.Add("github: $($Arguments -join ' ')") - $global:LASTEXITCODE = 0 - if ($Arguments[0] -eq 'api' -and $Arguments -notcontains 'POST' -and $Arguments -notcontains 'PATCH') { - if ($state.ApiError) { - $global:LASTEXITCODE = 1 - return $state.ApiError - } - if ($Arguments[1] -like '*/git/ref/tags/*') { - if ($null -ne $state.Tag) { - return (@{ object = $state.Tag } | ConvertTo-Json -Depth 10 -Compress) - } - } elseif ($Arguments[1] -like '*/git/tags/*') { - return ($state.TagObject | ConvertTo-Json -Depth 10 -Compress) - } elseif ($Arguments[1] -like '*/releases/tags/*') { - if ($null -ne $state.Release) { - return ($state.Release | ConvertTo-Json -Depth 10 -Compress) - } - } else { - throw "Unexpected read in publication fixture: $($Arguments -join ' ')." - } - $global:LASTEXITCODE = 1 - return 'gh: Not Found (HTTP 404)' - } - $state.GitHubWrites++ - throw "Unexpected mutation in a rejected publication fixture: $($Arguments -join ' ')." - }.GetNewClosure() + $script:state = New-PSModulePublicationFixture -Path $script:modulePath -Version '1.2.4' -Tag 'v1.2.4' } It 'rejects a conflicting standalone tag before a Gallery write or a GitHub mutation' { @@ -182,10 +114,11 @@ Describe 'Publish-PSModule source-bound preflight' { } It 'rejects different release asset bytes before a Gallery write or a GitHub mutation' { - $script:state.Tag = @{ type = 'commit'; sha = $env:GITHUB_SHA } + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $asset = @{ name = 'TestModule-1.2.4.zip' digest = 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + state = 'uploaded' } $script:state.Release = @{ id = 10 @@ -205,7 +138,7 @@ Describe 'Publish-PSModule source-bound preflight' { } It 'does not confuse GitHub API failure with an absent release or tag' { - $script:state.ApiError = 'gh: Service Unavailable (HTTP 503)' + $script:state.ApiError = "HTTP/2.0 503 Service Unavailable`n`n{`"message`":`"Service Unavailable`"}`ngh: Service Unavailable (HTTP 503)" $failure = $null try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } @@ -243,7 +176,10 @@ Describe 'Publish-PSModule source-bound preflight' { It 'rejects an unverifiable annotated tag rather than trusting its release target hint' { $script:state.Tag = @{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } $script:state.TagObject = @{ - object = @{ type = 'tree'; sha = $env:GITHUB_SHA } + sha = $script:state.Tag.sha + tag = 'v1.2.4' + message = 'Legacy annotation.' + object = @{ type = 'tree'; sha = $env:GITHUB_SHA } } $failure = $null @@ -277,4 +213,118 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 $script:state.Calls.Count | Should -Be 0 } + + It 'claims the exact source and tested bytes before a valid new Gallery publication' { + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.GitHubCreationUpload | Should -Be 0 + $script:state.Calls.IndexOf('tag-ref-create') | Should -BeLessThan $script:state.Calls.IndexOf('gallery-write') + $script:state.TagObject.object.sha | Should -BeExactly $env:GITHUB_SHA + $claim = Get-PSModulePublicationClaim -Message $script:state.TagObject.message + $claim.ArtifactSha256 | Should -BeExactly $script:state.ExpectedClaim.ArtifactSha256 + $claim.AssetSha256 | Should -BeExactly $script:state.ExpectedClaim.AssetSha256 + } + + It 'retains a proven exact-version Gallery package without any duplicate upload' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + $script:state.RawPackage = Join-Path -Path $TestDrive -ChildPath 'original.nupkg' + New-PSModuleFixturePackage -Path $script:modulePath -DestinationPath $script:state.RawPackage -Version '1.2.4' + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $script:state.Calls | Should -Contain 'raw-package-read' + } + + It 'rejects a foreign concurrent claim before Gallery or GitHub release creation/upload' { + $script:state.OnTagCreate = { + param($State) + $foreign = $State.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.CommitSha = '2222222222222222222222222222222222222222' + Set-PSModuleFixtureClaim -State $State -Claim $foreign -TagSha '4444444444444444444444444444444444444444' + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubCreationUpload | Should -Be 0 + $script:state.Tag.sha | Should -BeExactly '4444444444444444444444444444444444444444' + $failure.Exception.Message | Should -Match 'belongs|source|ownership' + } + + It 'rejects a changed same-source claim during the final pre-Gallery recheck' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.OnTagRead = { + param($State) + if ($State.TagReads -eq 3) { + $foreign = $State.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.AssetSha256 = 'a' * 64 + Set-PSModuleFixtureClaim -State $State -Claim $foreign -TagSha '4444444444444444444444444444444444444444' + } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'artifact|asset|contents' + } + + It 'rejects malformed provenance before all external writes' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.TagObject.message = '' + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure | Should -Not -BeNullOrEmpty + } + + It 'rejects changed Gallery package bytes despite matching numeric version and source claim' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + $script:state.RawPackage = Join-Path -Path $TestDrive -ChildPath 'foreign.nupkg' + $payloadPath = Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1' + $original = [IO.File]::ReadAllBytes($payloadPath) + [IO.File]::WriteAllText($payloadPath, '# Foreign package bytes.') + New-PSModuleFixturePackage -Path $script:modulePath -DestinationPath $script:state.RawPackage -Version '1.2.4' + [IO.File]::WriteAllBytes($payloadPath, $original) + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'Gallery package.*different|unverifiable' + } + + It 'rejects another PR footer even when tag, source, and ZIP proof match' { + $script:state.ExpectedClaim.PullRequest = 42 + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '{"Number":42}' + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $foreign = @{ Repository = 'PSModule/TestModule'; PullRequest = 99; CommitSha = $env:GITHUB_SHA } | + ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($foreign)) + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.4' + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false + body = "Notes.`n" + assets = @() + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'pull request' + } } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 index 80368687..dc35a512 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -6,17 +6,25 @@ param() BeforeAll { + $script:environmentMutex = [Threading.Mutex]::new($false, "PSModule-ActionTests-Environment-$PID") + if (-not $script:environmentMutex.WaitOne([TimeSpan]::FromMinutes(5))) { + throw 'Cannot acquire the process environment lock for action tests.' + } Import-Module -Name 'PSModule' -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Publish-PSModule.TestFixture.psm1') -Force $script:publishScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/publish.ps1' $script:environmentVariableNames = @( 'GITHUB_EVENT_PATH' 'GITHUB_REPOSITORY' + 'GITHUB_SHA' 'GITHUB_WORKSPACE' 'PSMODULE_PUBLISH_PSMODULE_INPUT_Name' 'PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath' 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' ) $script:originalEnvironment = @{} @@ -26,16 +34,17 @@ BeforeAll { } AfterAll { - foreach ($name in $script:environmentVariableNames) { - [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + try { + foreach ($name in $script:environmentVariableNames) { + [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + } + foreach ($name in @('Find-PSResource', 'Publish-PSResource', 'Resolve-PSModuleDependency', 'gh', 'Save-PSResource', 'Invoke-WebRequest')) { + Remove-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue + } + } finally { + $script:environmentMutex.ReleaseMutex() + $script:environmentMutex.Dispose() } - # Set-Item accepts 'function:global:X' and creates a function named 'X' in the global scope, but - # Remove-Item and Get-Item do not resolve that same path back to it, and fail silently rather than - # erroring. Removing by name is what actually deletes the shims; leaving them behind would shadow the - # real cmdlets for every test file that runs later in the session. - Remove-Item -Path 'Function:\Find-PSResource' -ErrorAction SilentlyContinue - Remove-Item -Path 'Function:\Publish-PSResource' -ErrorAction SilentlyContinue - Remove-Item -Path 'Function:\Resolve-PSModuleDependency' -ErrorAction SilentlyContinue } Describe 'Publish-PSModule recovery' { @@ -66,31 +75,25 @@ Describe 'Publish-PSModule recovery' { $env:GITHUB_EVENT_PATH = $eventPath $env:GITHUB_REPOSITORY = "PSModule/$script:moduleName" + $env:GITHUB_SHA = '1111111111111111111111111111111111111111' $env:GITHUB_WORKSPACE = $script:workspacePath $env:PSMODULE_PUBLISH_PSMODULE_INPUT_Name = $script:moduleName $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath = '.PSModule/module' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'test-key' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' - # The publish script runs in its own scope, so a $script: flag set inside a shim never reaches the - # test. A hashtable captured by GetNewClosure() is shared by reference and records the call reliably. - # The closure captures local variables only, so $calls must be local here, not $script:-qualified. - $calls = @{ PublishInvoked = $false } - $script:calls = $calls - - Set-Item -Path function:global:Resolve-PSModuleDependency -Value {} - Set-Item -Path function:global:Find-PSResource -Value { - [PSCustomObject]@{ Name = 'TestModule'; Version = '1.2.4' } - } - Set-Item -Path function:global:Publish-PSResource -Value { - $calls.PublishInvoked = $true - }.GetNewClosure() + $script:state = New-PSModulePublicationFixture -Path $script:modulePath -Version '1.2.4' -Tag 'v1.2.4' } - It 'skips Gallery publication when the resolved version already exists' { - { & $script:publishScriptPath } | Should -Not -Throw + It 'rejects a Gallery-only version whose source and artifact proof is missing' { + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*proof*' - $script:calls.PublishInvoked | Should -BeFalse + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 } It 'publishes when the resolved version is not in the Gallery' { @@ -108,9 +111,9 @@ Describe 'Publish-PSModule recovery' { -ErrorId 'PackageNotFound' -Category ObjectNotFound -TargetObject $Name } - { & $script:publishScriptPath } | Should -Not -Throw + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw - $script:calls.PublishInvoked | Should -BeTrue + $script:state.GalleryWrites | Should -Be 1 } It 'fails without publishing when the Gallery lookup errors for another reason' { @@ -128,8 +131,9 @@ Describe 'Publish-PSModule recovery' { -ErrorId 'HttpRequestCallFailure' -Category ResourceUnavailable -TargetObject $Name } - { & $script:publishScriptPath } | Should -Throw + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw - $script:calls.PublishInvoked | Should -BeFalse + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 } } diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 27c1b73c..b3973d2a 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -51,6 +51,10 @@ $ErrorActionPreference = 'Stop' $PSStyle.OutputRendering = 'Ansi' Import-Module -Name 'PSModule' -Force +Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Release-PSModule.Helpers.psm1') -Force +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Publish-PSModule', 'src', 'Publish-PSModule.Helpers.psm1' + )) -Force LogGroup 'Load inputs' { $env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/' @@ -181,99 +185,156 @@ LogGroup 'Resolve version from manifest' { # A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes. LogGroup 'Create GitHub release' { - $releaseCreateCommand = @('release', 'create', $releaseTag) + $releaseCreateCommand = @('release', 'create', $releaseTag, '--repo', $env:GITHUB_REPOSITORY, '--verify-tag') $notesFilePath = $null $releaseExists = $false - - if (-not $whatIf) { - $encodedReleaseTag = [System.Uri]::EscapeDataString($releaseTag) - $releaseLookupResult = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$encodedReleaseTag" --jq '.html_url' 2>&1 - $releaseLookupExitCode = $LASTEXITCODE - - if ($releaseLookupExitCode -eq 0) { - $releaseURL = ($releaseLookupResult | Out-String).Trim() - $releaseExists = $true - Write-Host "GitHub release [$releaseTag] already exists. Resuming its artifact upload." - } elseif (($releaseLookupResult | Out-String) -notmatch 'HTTP 404') { - throw "Unable to determine whether release [$releaseTag] exists: $($releaseLookupResult | Out-String)" - } - } - - if (-not $releaseExists) { - if ($usePRTitleAsReleaseName -and $pullRequest.Title) { - $releaseCreateCommand += @('--title', $pullRequest.Title) - Write-Host "Using PR title as release name: [$($pullRequest.Title)]" - } else { - $releaseCreateCommand += @('--title', $releaseTag) - } - - # Build release notes content. Uses a file to preserve special characters. - if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pullRequest.Title -and $pullRequest.Body) { - $notes = "# $($pullRequest.Title) (#$prNumber)`n`n$($pullRequest.Body)" - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR title as H1 heading with link and body as release notes' - } elseif ($usePRBodyAsReleaseNotes -and $pullRequest.Body) { - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $pullRequest.Body -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR body as release notes' - } elseif (-not [string]::IsNullOrWhiteSpace($commitMessage)) { - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $commitMessage -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using the pushed commit message as release notes' - } else { - $releaseCreateCommand += @('--generate-notes') + $sourceCommit = if (-not [string]::IsNullOrWhiteSpace($commitSha)) { $commitSha } else { $env:GITHUB_SHA } + $zipFileName = "$name-$publishPSVersion.zip" + $zipDirectory = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath "PSModuleRelease-$([guid]::NewGuid())" + $zipPath = Join-Path -Path $zipDirectory -ChildPath $zipFileName + try { + if (-not $whatIf) { + $null = New-Item -Path $zipDirectory -ItemType Directory + $null = New-PSModuleReleaseArchive -Path $modulePath -DestinationPath $zipPath + $claim = [pscustomobject]@{ + Repository = $env:GITHUB_REPOSITORY + PullRequest = if ($null -eq $prNumber) { 0 } else { $prNumber } + CommitSha = $sourceCommit + ModuleName = $name + GalleryVersion = $publishPSVersion + ReleaseTag = $releaseTag + ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } + ArtifactSha256 = Get-PSModuleArtifactDigest -Path $modulePath + AssetName = $zipFileName + AssetSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + } + $publicationReader = Get-PSModulePublicationReader -Repository $env:GITHUB_REPOSITORY + $publicationState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + $publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader + $releaseExists = $null -ne $publicationState.Release + if ($releaseExists) { + $releaseURL = $publicationState.Release.html_url + Write-Host "GitHub release [$releaseTag] already belongs to this source and artifact. Resuming its upload." + } } - if ($createPrerelease) { - $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') - } elseif (-not [string]::IsNullOrWhiteSpace($commitSha)) { - $releaseCreateCommand += @('--target', $commitSha) - } + if (-not $releaseExists) { + if ($usePRTitleAsReleaseName -and $pullRequest.Title) { + $releaseCreateCommand += @('--title', $pullRequest.Title) + Write-Host "Using PR title as release name: [$($pullRequest.Title)]" + } else { + $releaseCreateCommand += @('--title', $releaseTag) + } - try { - if ($whatIf) { - Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" - $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" + # Build release notes content. Uses a file to preserve special characters. + if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pullRequest.Title -and $pullRequest.Body) { + $notes = "# $($pullRequest.Title) (#$prNumber)`n`n$($pullRequest.Body)" + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR title as H1 heading with link and body as release notes' + } elseif ($usePRBodyAsReleaseNotes -and $pullRequest.Body) { + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $pullRequest.Body -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR body as release notes' + } elseif (-not [string]::IsNullOrWhiteSpace($commitMessage)) { + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $commitMessage -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using the pushed commit message as release notes' } else { - $releaseURL = gh @releaseCreateCommand - if ($LASTEXITCODE -ne 0) { - throw "Failed to create the release [$releaseTag]." + $releaseCreateCommand += @('--generate-notes') + } + + if ([string]::IsNullOrWhiteSpace($sourceCommit)) { + throw 'CommitSha or the original GITHUB_SHA is required to target the exact tested source.' + } + $releaseCreateCommand += @('--target', $sourceCommit) + if ($createPrerelease) { + $releaseCreateCommand += '--prerelease' + if ($prNumber) { + $ownership = [pscustomobject]@{ + Repository = $env:GITHUB_REPOSITORY + PullRequest = [long]$prNumber + CommitSha = $sourceCommit + } | ConvertTo-Json -Compress + $encodedOwnership = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ownership)) + $ownershipFooter = "" + if ($null -eq $notesFilePath) { + if ($whatIf) { + Write-Host ( + "WhatIf: gh api repos/$env:GITHUB_REPOSITORY/releases/generate-notes --method POST " + + "--field tag_name=$releaseTag --field target_commitish=$sourceCommit" + ) + $generatedBody = '' + } else { + $generatedNotes = gh api "repos/$env:GITHUB_REPOSITORY/releases/generate-notes" --method POST ` + --field "tag_name=$releaseTag" --field "target_commitish=$sourceCommit" + if ($LASTEXITCODE -ne 0) { + throw "Cannot generate prerelease notes (gh exited $LASTEXITCODE)." + } + $generated = $generatedNotes | ConvertFrom-Json -ErrorAction Stop + if ($generated.PSObject.Properties.Name -notcontains 'body') { + throw 'Generated release notes have no body. Refusing incomplete notes.' + } + $generatedBody = [string]$generated.body + } + $notesFilePath = [IO.Path]::GetTempFileName() + Set-Content -LiteralPath $notesFilePath -Value $generatedBody -Encoding utf8 + $releaseCreateCommand = @($releaseCreateCommand | Where-Object { $_ -ne '--generate-notes' }) + $releaseCreateCommand += @('--notes-file', $notesFilePath) + } + Add-Content -LiteralPath $notesFilePath -Value "`n$ownershipFooter" -Encoding utf8 } } - } finally { - if ($notesFilePath -and (Test-Path -Path $notesFilePath)) { - Remove-Item -Path $notesFilePath -Force + + try { + if ($whatIf) { + Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" + $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" + } else { + $releaseURL = gh @releaseCreateCommand + if ($LASTEXITCODE -ne 0) { + throw "Failed to create the release [$releaseTag]." + } + } + } finally { + if ($notesFilePath -and (Test-Path -Path $notesFilePath)) { + Remove-Item -Path $notesFilePath -Force + } } } - } - # Attach the built module as a release artifact so consumers can download the exact bytes. - $zipFileName = "$name-$publishPSVersion.zip" - $zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipFileName - if (Test-Path -Path $zipPath) { - Remove-Item -Path $zipPath -Force - } - if ($whatIf) { - Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force" - Write-Host "WhatIf: gh release upload $releaseTag $zipPath --clobber" - } else { - Write-Host "Compressing module to [$zipPath]" - Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force - try { - gh release upload $releaseTag $zipPath --clobber - if ($LASTEXITCODE -ne 0) { - throw "Failed to upload module artifact to release [$releaseTag]." + if ($whatIf) { + Write-Host "WhatIf: New-PSModuleReleaseArchive -Path $modulePath -DestinationPath $zipPath" + Write-Host "WhatIf: gh release upload $releaseTag $zipPath --repo $env:GITHUB_REPOSITORY" + } else { + $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + if ($currentState.TagSha -cne $publicationState.TagSha -or $null -eq $currentState.Release -or + (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { + throw 'The release tag, release context, or tested artifact changed before upload. Refusing publication.' } - Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName" - } finally { - if (Test-Path -Path $zipPath) { - Remove-Item -Path $zipPath -Force + if (@($currentState.Release.assets).Count -eq 0) { + gh release upload $releaseTag $zipPath --repo $env:GITHUB_REPOSITORY + if ($LASTEXITCODE -ne 0) { + throw "Failed to upload module artifact to release [$releaseTag]." + } + Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName" + } else { + Write-Host "Existing asset [$zipFileName] has identical contents; retaining its immutable bytes." } } + } finally { + if ($notesFilePath -and (Test-Path -LiteralPath $notesFilePath)) { + Remove-Item -LiteralPath $notesFilePath -Force + } + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + if (Test-Path -LiteralPath $zipDirectory) { + Remove-Item -LiteralPath $zipDirectory -Force + } } if ($whatIf -and $prNumber) { diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index e24aeaee..0218610c 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -6,13 +6,21 @@ param() BeforeAll { + $script:environmentMutex = [Threading.Mutex]::new($false, "PSModule-ActionTests-Environment-$PID") + if (-not $script:environmentMutex.WaitOne([TimeSpan]::FromMinutes(5))) { + throw 'Cannot acquire the process environment lock for action tests.' + } Import-Module -Name 'PSModule' -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Publish-PSModule', 'tests', 'Publish-PSModule.TestFixture.psm1' + )) -Force $script:releaseScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/release.ps1' $script:environmentVariableNames = @( 'GITHUB_EVENT_PATH' 'GITHUB_OUTPUT' 'GITHUB_REPOSITORY' + 'GITHUB_SHA' 'GITHUB_WORKSPACE' 'RELEASE_ACTION_GH_CALL_LOG' 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' @@ -32,15 +40,17 @@ BeforeAll { } AfterAll { - foreach ($name in $script:environmentVariableNames) { - [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + try { + foreach ($name in $script:environmentVariableNames) { + [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + } + foreach ($name in @('gh', 'git', 'Find-PSResource', 'Publish-PSResource', 'Save-PSResource', 'Invoke-WebRequest')) { + Remove-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue + } + } finally { + $script:environmentMutex.ReleaseMutex() + $script:environmentMutex.Dispose() } - # Set-Item accepts 'function:global:X' and creates a function named 'X' in the global scope, but - # Remove-Item and Get-Item do not resolve that same path back to it, and fail silently rather than - # erroring. Removing by name is what actually deletes the shims; leaving them behind would shadow the - # real commands for every test file that runs later in the session. - Remove-Item -Path 'Function:\gh' -ErrorAction SilentlyContinue - Remove-Item -Path 'Function:\git' -ErrorAction SilentlyContinue } Describe 'Release-PSModule WhatIf' { @@ -86,6 +96,7 @@ Describe 'Release-PSModule WhatIf' { $env:GITHUB_EVENT_PATH = $eventPath $env:GITHUB_OUTPUT = $script:githubOutputPath $env:GITHUB_REPOSITORY = "PSModule/$script:moduleName" + $env:GITHUB_SHA = '1111111111111111111111111111111111111111' $env:GITHUB_WORKSPACE = $script:workspacePath $env:RELEASE_ACTION_GH_CALL_LOG = $script:ghCallLogPath $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $script:moduleName @@ -97,38 +108,40 @@ Describe 'Release-PSModule WhatIf' { $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1' $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = '' + $script:state = New-PSModulePublicationFixture -Path $script:modulePath ` + -Version '1.2.3-preview.1' -Tag 'v1.2.3-preview.1' -PullRequest 42 } It 'creates a prefixed prerelease tag without GitHub side effects' { - { & $script:releaseScriptPath } | Should -Not -Throw + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1' Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseUrl=https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' + $script:state.Calls.Count | Should -Be 0 } It 'resumes an existing release after a partial failure' { $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' - Set-Item -Path function:global:gh -Value { - [CmdletBinding()] - param( - [Parameter(ValueFromRemainingArguments)] - [string[]] $Arguments - ) - - Add-Content -Path $env:RELEASE_ACTION_GH_CALL_LOG -Value ($Arguments -join ' ') - $global:LASTEXITCODE = 0 - if ($Arguments[0] -eq 'api') { - return 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' - } + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $owner = @{ Repository = 'PSModule/TestModule'; PullRequest = 42; CommitSha = $env:GITHUB_SHA } | + ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.3-preview.1' + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' + prerelease = $true + body = "Original notes.`n" + assets = @() } - { & $script:releaseScriptPath } | Should -Not -Throw + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw - $ghCalls = Get-Content -Path $script:ghCallLogPath -Raw - $ghCalls | Should -Match 'api repos/PSModule/TestModule/releases/tags/v1.2.3-preview.1 --jq .html_url' + $ghCalls = $script:state.Calls -join "`n" + $ghCalls | Should -Match 'api repos/PSModule/TestModule/releases/tags/v1.2.3-preview.1 --include --method GET' $ghCalls | Should -Match 'release upload v1.2.3-preview.1' $ghCalls | Should -Match 'pr comment 42' $ghCalls | Should -Not -Match 'release create' + $ghCalls | Should -Not -Match '--clobber' Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1' } @@ -161,4 +174,85 @@ Describe 'Release-PSModule WhatIf' { $releaseOutput | Should -Match 'Using the pushed commit message as release notes' } + + It 'creates a prerelease from the exact source and preserves notes before its provenance footer' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.TagObject.object.sha | Should -BeExactly $env:GITHUB_SHA + ($script:state.Calls -join "`n") | Should -Match "--target $env:GITHUB_SHA" + ($script:state.Calls -join "`n") | Should -Not -Match '--target feature/release|--clobber' + $script:state.Release.body | Should -Match '# Create test release \(#42\)' + $script:state.Release.body | Should -Match 'Release notes' + $owner = Get-PSModulePrereleaseOwnership -Body $script:state.Release.body + $owner.PullRequest | Should -Be 42 + $owner.CommitSha | Should -BeExactly $env:GITHUB_SHA + $script:state.GalleryWrites | Should -Be 0 + } + + It 'preserves complete generated notes before its provenance footer' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'false' + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.Release.body | Should -Match '^Complete independently generated fixture notes\.' + (Get-PSModulePrereleaseOwnership -Body $script:state.Release.body).PullRequest | Should -Be 42 + } + + It 'rejects another producing PR footer despite a matching tag and source' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $owner = @{ Repository = 'PSModule/TestModule'; PullRequest = 99; CommitSha = $env:GITHUB_SHA } | + ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.3-preview.1' + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' + prerelease = $true + body = "Foreign PR notes.`n" + assets = @() + } + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*pull request*' + + $script:state.GitHubWrites | Should -Be 0 + $script:state.GalleryWrites | Should -Be 0 + } + + It 'retains existing identical asset bytes without another upload' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + $firstWrites = $script:state.GitHubWrites + $originalBody = $script:state.Release.body + $originalDigest = $script:state.Release.assets[0].digest + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GitHubWrites | Should -Be $firstWrites + $script:state.Release.body | Should -BeExactly $originalBody + $script:state.Release.assets[0].digest | Should -BeExactly $originalDigest + } + + It 'rejects a tag changed after preflight before the missing asset upload' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + $script:state.Release.assets = @() + $script:state.TagReads = 0 + $script:state.GitHubWrites = 0 + $script:state.OnTagRead = { + param($State) + if ($State.TagReads -eq 3) { + $foreign = $State.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.CommitSha = '2222222222222222222222222222222222222222' + Set-PSModuleFixtureClaim -State $State -Claim $foreign -TagSha '4444444444444444444444444444444444444444' + } + } + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*source*' + + $script:state.GitHubWrites | Should -Be 0 + @($script:state.Release.assets).Count | Should -Be 0 + } } diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 4042920c..0ac1a030 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -63,6 +63,8 @@ jobs: ModulePath: ${{ fromJson(inputs.Settings).WorkingDirectory }}/.PSModule/module PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} + ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} From c3b9ff4e29cb12069a5e68a34b4d60faafe8a910 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:31:53 +0100 Subject: [PATCH 05/21] Stop version resolution on unverifiable Gallery reads --- .../src/Resolve-PSModuleVersion.Helpers.psm1 | 56 +++++------ ...solve-PSModuleVersion.ReadErrors.Tests.ps1 | 92 +++++++++++++++++++ 2 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 .github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.ReadErrors.Tests.ps1 diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index ab9b8560..dccdef34 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -1,4 +1,6 @@ -function Split-CommaSeparatedList { +$ErrorActionPreference = 'Stop' + +function Split-CommaSeparatedList { <# .SYNOPSIS Splits a comma-separated string into a trimmed, non-empty array. @@ -455,7 +457,8 @@ function Get-LatestPSGalleryVersion { .DESCRIPTION Queries the PowerShell Gallery for the latest published version of the module. - Retries up to five times with a ten-second delay between attempts. + Only verified PackageNotFound absence provides a first-release baseline. + Authentication, API, and incomplete-metadata errors stop version resolution. .OUTPUTS PSSemVer representing the latest PSGallery version. @@ -475,32 +478,22 @@ function Get-LatestPSGalleryVersion { ) LogGroup 'Get latest version - PSGallery' { - $retryCount = 5 - $retryDelaySeconds = 10 - $latest = $null - - for ($i = 1; $i -le $retryCount; $i++) { - try { - Write-Host "Finding module [$ModuleName] in the PowerShell Gallery." - $latest = Find-PSResource -Name $ModuleName -Repository PSGallery -Verbose:$false - Write-Host ($latest | Format-Table | Out-String) - break - } catch { - if ($i -eq $retryCount) { - Write-Warning "Failed to find the module [$ModuleName] in the PowerShell Gallery." - Write-Warning $_.Exception.Message - } - Start-Sleep -Seconds $retryDelaySeconds + Write-Host "Finding module [$ModuleName] in the PowerShell Gallery." + try { + $packages = @(Find-PSResource -Name $ModuleName -Repository PSGallery -ErrorAction Stop) + } catch { + $failure = $_ + if ($failure.FullyQualifiedErrorId -notlike 'PackageNotFound,*') { + throw } + Write-Host "The module [$ModuleName] is absent from the Gallery; using the first-release baseline [0.0.0]." + return (New-PSSemVer -Version '0.0.0') } - - $version = if ($latest.Version) { - New-PSSemVer -Version ($latest.Version).ToString() - } else { - Write-Warning "Could not find module online. Using '0.0.0'." - New-PSSemVer -Version '0.0.0' + if ($packages.Count -ne 1 -or $packages[0].Name -ine $ModuleName -or + [string]::IsNullOrWhiteSpace([string]$packages[0].Version)) { + throw "Cannot verify the latest Gallery version metadata for [$ModuleName]." } - + $version = New-PSSemVer -Version ([string]$packages[0].Version) Write-Host "PSGallery version: [$($version.ToString())]" $version } @@ -600,9 +593,18 @@ function Get-NextPrereleaseNumber { Prerelease = $true Repository = 'PSGallery' Verbose = $false - ErrorAction = 'SilentlyContinue' + ErrorAction = 'Stop' + } + try { + $galleryVersions = @(Find-PSResource @params) + } catch { + $failure = $_ + if ($failure.FullyQualifiedErrorId -notlike 'PackageNotFound,*') { + throw + } + $galleryVersions = @() } - $matchingPSGalleryPrereleases = Find-PSResource @params | + $matchingPSGalleryPrereleases = $galleryVersions | Where-Object { "$($_.Version.Major).$($_.Version.Minor).$($_.Version.Build)" -eq $BaseVersion } | Where-Object { $_.Prerelease -like "$PrereleaseName*" } diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.ReadErrors.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.ReadErrors.Tests.ps1 new file mode 100644 index 00000000..263a3738 --- /dev/null +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.ReadErrors.Tests.ps1 @@ -0,0 +1,92 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester setup state is used in test blocks and command replacements.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeAll { + Import-Module -Name PSModule -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', 'src', 'Resolve-PSModuleVersion.Helpers.psm1' + )) -Force + $script:originalFind = (Get-Item -LiteralPath 'Function:\Find-PSResource' -ErrorAction SilentlyContinue).ScriptBlock + $script:originalSleep = (Get-Item -LiteralPath 'Function:\Start-Sleep' -ErrorAction SilentlyContinue).ScriptBlock +} + +AfterAll { + foreach ($name in @('Find-PSResource', 'Start-Sleep')) { + Remove-Item -LiteralPath "Function:\$name" -ErrorAction SilentlyContinue + } + if ($null -ne $script:originalFind) { + Set-Item -Path function:global:Find-PSResource -Value $script:originalFind + } + if ($null -ne $script:originalSleep) { + Set-Item -Path function:global:Start-Sleep -Value $script:originalSleep + } +} + +Describe 'Version lookup errors are not version baselines' { + BeforeEach { + $state = @{ Message = ''; ErrorId = ''; Package = $null } + $script:state = $state + Set-Item -Path function:global:Start-Sleep -Value {} + Set-Item -Path function:global:Find-PSResource -Value { + [CmdletBinding()] + param( + [string] $Name, + [string] $Repository, + [string] $Version, + [switch] $Prerelease + ) + if ($Name -cne 'TestModule' -or $Repository -cne 'PSGallery' -or + ($Version -and ($Version -cne '*' -or -not $Prerelease))) { + throw 'Unexpected Gallery boundary in the version-reader fixture.' + } + if ($state.Message) { + Write-Error -Message $state.Message -ErrorId $state.ErrorId -Category ResourceUnavailable + return + } + $state.Package + }.GetNewClosure() + } + + It 'retains verified Gallery package absence as the first-release baseline' { + $script:state.Message = 'The exact module is absent.' + $script:state.ErrorId = 'PackageNotFound' + + (Get-LatestPSGalleryVersion -ModuleName TestModule).ToString() | Should -BeExactly '0.0.0' + } + + It 'surfaces Gallery authentication and API errors before resolving a publishable version' -ForEach @( + @{ Message = 'Gallery authentication failed (HTTP 401).' } + @{ Message = 'Gallery Service Unavailable (HTTP 503).' } + ) { + $script:state.Message = $Message + $script:state.ErrorId = 'HttpRequestCallFailure' + + { Get-LatestPSGalleryVersion -ModuleName TestModule } | Should -Throw + } + + It 'rejects a successful lookup with no version metadata rather than returning 0.0.0' { + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = $null } + + { Get-LatestPSGalleryVersion -ModuleName TestModule } | Should -Throw '*version*' + } + + It 'retains the actual stable version without an extra bump policy' { + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = [version]'1.2.3' } + + (Get-LatestPSGalleryVersion -ModuleName TestModule).ToString() | Should -BeExactly '1.2.3' + } + + It 'does not interpret a prerelease enumeration outage as counter zero' { + $script:state.Message = 'Gallery enumeration failed (HTTP 503).' + $script:state.ErrorId = 'HttpRequestCallFailure' + $parameters = @{ ModuleName = 'TestModule'; BaseVersion = '1.2.4'; PrereleaseName = 'preview'; Releases = @() } + + { Get-NextPrereleaseNumber @parameters } | Should -Throw + } +} From 84fcb9c22278982e627fb94683b7f69910a75e7a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:31:53 +0100 Subject: [PATCH 06/21] Isolate artifact fixtures for shuffled action tests --- .../Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 index aa3ce778..6bceee19 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -93,7 +93,7 @@ Describe 'Source-bound publication proof' { Describe 'Tested artifact identity' { BeforeEach { - $script:modulePath = Join-Path -Path $TestDrive -ChildPath 'TestModule' + $script:modulePath = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) -AdditionalChildPath 'TestModule' $null = New-Item -Path $script:modulePath -ItemType Directory -Force [IO.File]::WriteAllText( (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'), From 4cf52d8854518b60b0cb7a10f73300cef70ebad5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:31:54 +0100 Subject: [PATCH 07/21] Document source-bound publication and immutable retry requirements --- .github/actions/Publish-PSModule/README.md | 16 ++++++++++++-- .github/actions/Release-PSModule/README.md | 12 +++++++++-- .../content/guides/versioning-and-releases.md | 21 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 99602dfb..253ef7a8 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -1,6 +1,10 @@ # Publish-PSModule -Publishes a pre-versioned PowerShell module artifact to the PowerShell Gallery. GitHub Release creation is intentionally handled by the separate [Release-PSModule](../Release-PSModule/README.md) action. +Publishes a pre-versioned, tested PowerShell module artifact to the PowerShell Gallery after source-bound ownership preflight. GitHub Release creation is handled by the separate [Release-PSModule](../Release-PSModule/README.md) action. + +The publisher verifies the original source, exact resolved tag/version, producing repository/PR or direct-release context, and tested bytes before an external versioned-artifact mutation. An absent version is reserved with a create-only annotated tag containing the file-tree and ZIP SHA-256 proof. Existing tags are never updated or deleted. + +An existing Gallery package is retained only when its raw nupkg payload matches that proof and the tested artifact. Package code is never imported to establish ownership. Unknown legacy proof, foreign tags/releases/assets, changed claims, authentication errors, and API failures stop publication explicitly. ## Inputs @@ -10,6 +14,9 @@ Publishes a pre-versioned PowerShell module artifact to the PowerShell Gallery. | `ModulePath` | Path containing the built `/` module directory. | No | `.PSModule/module` | | `ArtifactName` | Name of the module artifact to download. | No | `module` | | `PSGALLERY_API_KEY` | PowerShell Gallery API key. | Yes | N/A | +| `ReleaseTag` | Exact tag already resolved as `Publish.Module.Resolution.FullVersion`. | Yes | N/A | +| `CommitSha` | Immutable commit that produced the tested artifact. | No | Original workflow `GITHUB_SHA` | +| `PullRequest` | Normalized producing-PR context; empty for a direct stable release. | No | Empty | | `WhatIf` | Logs publishing operations without publishing the module. | No | `false` | | `WorkingDirectory` | Directory where the publishing script runs. | No | `.` | @@ -27,6 +34,11 @@ This action does not provide outputs. ModulePath: .PSModule/module ArtifactName: module PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} + PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} ``` -Use [Release-PSModule](../Release-PSModule/README.md) in a separate workflow step to create the GitHub release from the same artifact. +Provide `GH_TOKEN` with contents-write access scoped to the consumer repository. The reusable workflow supplies its existing GitHub App token and resolved context internally. + +Use [Release-PSModule](../Release-PSModule/README.md) in a separate step to create the GitHub release from the same source and tested artifact. A retry must retain the original coordinates and bytes; unproven legacy publication requires reviewed forward-only recovery, not numeric guessing, retagging, or metadata changes. diff --git a/.github/actions/Release-PSModule/README.md b/.github/actions/Release-PSModule/README.md index c86c9e30..9df4b52a 100644 --- a/.github/actions/Release-PSModule/README.md +++ b/.github/actions/Release-PSModule/README.md @@ -1,6 +1,6 @@ # Release-PSModule -Creates or resumes a GitHub release for a pre-versioned PowerShell module artifact. The action attaches the exact built module ZIP to the release and reports the result on the source pull request. +Creates or resumes a GitHub release for a pre-versioned, tested PowerShell module artifact. The action verifies the same immutable source/context/artifact claim as the Gallery publisher, attaches repeatable module ZIP bytes, and reports the result on the producing pull request when available. ## Inputs @@ -10,6 +10,8 @@ Creates or resumes a GitHub release for a pre-versioned PowerShell module artifa | `ModulePath` | Path containing the built `/` module directory. | No | `.PSModule/module` | | `ArtifactName` | Name of the module artifact to download. | No | `module` | | `ReleaseTag` | Full GitHub release tag resolved by `Resolve-PSModuleVersion`. | Yes | N/A | +| `CommitSha` | Original immutable build source for stable and prerelease tags. | No | Original workflow `GITHUB_SHA` | +| `PullRequest` | Normalized producing-PR context; empty for a direct stable release. | No | Empty | | `WhatIf` | Logs release operations without creating or uploading anything. | No | `false` | | `WorkingDirectory` | Directory where the release script runs. | No | `.` | | `UsePRTitleAsReleaseName` | Uses the pull request title as the release name. | No | `false` | @@ -36,6 +38,12 @@ Pass the Plan job's resolved `FullVersion` as `ReleaseTag` so the configured tag ModulePath: .PSModule/module ArtifactName: module ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} + PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} ``` -The action requires a `pull_request` event and a built module artifact. If a release already exists for `ReleaseTag`, it resumes by replacing the module ZIP, which makes retrying a partial release safe. +The action supports normalized pull-request context and direct stable releases. Provide repository-scoped contents-write `GH_TOKEN` access and the original tested artifact/source. Tags resolve to actual commits, including annotated tags; mutable branch names and release target hints are not ownership proof. + +Release creation and upload recheck the tag, provenance, and artifact identity. A missing upload can resume. An existing asset is retained only when its published SHA-256 matches the exact local ZIP; differing, ambiguous, or unverifiable bytes stop the action. Uploads never use `--clobber`, and tags are never retargeted. + +Complete authored or generated prerelease notes precede a final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release. Legacy records without proof require reviewed forward-only recovery; the action does not fabricate provenance or alter consumer metadata. diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index fe226002..8d36c6d5 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -73,6 +73,27 @@ Each publication produces three linked, immutable artifacts: Release names and notes can be generated from the pull request — see [Configuring the pipeline](configuring-the-pipeline.md). +## Publication ownership + +Before taking a Gallery version, the publisher verifies the immutable consumer source, the Plan job's exact +resolved version/tag, the producing repository/PR or direct-release context, and the tested module bytes. +An absent version tag is reserved with a create-only annotated claim containing the tested file-tree and +release-ZIP SHA-256 digests. Existing tags are not updated or deleted. + +Standalone tags, annotated tags, releases, provenance footers, module assets, and exact Gallery packages must +agree with that claim. Raw nupkg files are read without importing package code. Verified absence is different +from an authentication/API failure or unknown ownership proof; errors and unavailable proof stop publication. + +The publisher rechecks the claim and tested files immediately before Gallery upload. GitHub release creation +and upload repeat the ownership checks. Missing uploads may resume, identical published ZIP bytes are retained, +and differing or unverifiable assets are never clobbered. Complete authored or generated prerelease notes remain +before their final producing-repository/PR/source footer. + +A retry retains the original source, resolved coordinates, release context, and tested bytes. Legacy incomplete +publications without proof require reviewed forward-only recovery, not a guessed version, retagging, asset +replacement, or metadata edits. See the [publisher](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions/Publish-PSModule) +and [release action](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions/Release-PSModule) contracts. + ## Linear versioning Only a single linear ancestry of versions is maintained. Old versions are not patched: if a security issue is found on From cb56b2731bd253abb3920cb4bd260ea903a84959 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 17:06:19 +0100 Subject: [PATCH 08/21] Reject invalid producing context and changed claims before release mutations --- .../src/Publish-PSModule.Helpers.psm1 | 9 ++- .../actions/Publish-PSModule/src/publish.ps1 | 2 +- .../Publish-PSModule.Preflight.Tests.ps1 | 59 +++++++++++++++++++ .../tests/Publish-PSModule.TestFixture.psm1 | 4 ++ .../src/Release-PSModule.Helpers.psm1 | 43 ++++++++++++++ .../actions/Release-PSModule/src/release.ps1 | 18 ++++-- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 25 ++++++++ 7 files changed, 152 insertions(+), 8 deletions(-) diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index 53e1cc9b..3eb53f4c 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -448,6 +448,9 @@ function Get-PSModulePublicationState { } } if ($null -ne $release) { + if ($release.draft -eq $true) { + throw 'The existing release is a draft. Refusing Gallery publication or a success-shaped incomplete release.' + } if ($release.tag_name -cne $expected.ReleaseTag -or [string]::IsNullOrWhiteSpace($release.html_url) -or $release.prerelease -isnot [bool] -or @@ -532,10 +535,10 @@ function New-PSModulePublicationTag { ) $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($null -ne $State.TagSha -and $current.TagSha -cne $State.TagSha) { + throw 'The existing publication tag changed or disappeared after preflight. Refusing Gallery publication.' + } if ($null -ne $current.TagSha) { - if ($null -ne $State.TagSha -and $current.TagSha -cne $State.TagSha) { - throw 'The existing publication tag changed after preflight. Refusing Gallery publication.' - } return $current } if (-not $PSCmdlet.ShouldProcess("$($Claim.Repository):$($Claim.ReleaseTag)", 'Create source-bound annotated version tag')) { diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 7543fc93..7449c992 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -77,7 +77,7 @@ LogGroup 'Load release context' { } } - $prNumber = $pullRequest.Number + $prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest if ($prNumber) { Write-Host "Pull request: [#$prNumber]" } else { diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 6a071a97..734b8550 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -327,4 +327,63 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 $failure.Exception.Message | Should -Match 'pull request' } + + It 'rejects an unreadable final ownership footer on a stable release' -ForEach @( + @{ Footer = '' } + @{ Footer = '' } + ) { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false; draft = $false; body = "Notes.`n$Footer"; assets = @() + } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*footer*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects a disappeared previously claimed tag rather than recreating it' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.OnTagRead = { + param($State) + if ($State.TagReads -eq 2) { + $State.Tag = $null + $State.TagObject = $null + } + } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*changed*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects supplied PR context without a positive identity before all writes' -ForEach @( + @{ Json = '{}' } + @{ Json = '{"Number":0}' } + @{ Json = '{"Number":"unknown"}' } + @{ Json = '42' } + ) { + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = $Json + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*pull request*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects a draft release before Gallery publication rather than reporting completed publication' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false; draft = $true; body = 'Unpublished draft notes.'; assets = @() + } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*draft*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 index 2c5bce0c..5b102f99 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 @@ -199,6 +199,7 @@ function New-PSModulePublicationFixture { TagReads = 0 OnTagRead = $null OnTagCreate = $null + OnGeneratedNotes = $null FailReleaseCreate = $false ExpectedClaim = [pscustomobject]@{ Repository = "PSModule/$name" @@ -312,6 +313,9 @@ function New-PSModulePublicationFixture { $status = 201 } } elseif ($endpoint -ceq "repos/$($claim.Repository)/releases/generate-notes") { + if ($null -ne $state.OnGeneratedNotes) { + & $state.OnGeneratedNotes $state + } $value = @{ body = 'Complete independently generated fixture notes.' } } else { throw "Unexpected create API in the publication fixture: [$endpoint]." diff --git a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 index 550761ca..1e316a51 100644 --- a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 +++ b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 @@ -99,6 +99,9 @@ function Get-PSModulePrereleaseOwnership { $match = [regex]::Match($Body, '(?:^|\r?\n)\s*\z') if (-not $match.Success) { + if ($Body -match '(?:^|\r?\n)" - $tagFields = @{ - tag = $Claim.ReleaseTag - message = $message - object = $Claim.CommitSha - type = 'commit' - } - $tag = (Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/tags" ` - -Method POST -Field $tagFields).Value - if ($tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -cne $Claim.ReleaseTag -or - $tag.object.type -cne 'commit' -or $tag.object.sha -cne $Claim.CommitSha -or $tag.message -cne $message) { - throw 'The create-only annotated tag response did not preserve the exact publication claim.' - } - $reference = Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/refs" ` - -Method POST -Field @{ ref = "refs/tags/$($Claim.ReleaseTag)"; sha = $tag.sha } -AllowConflict - if ($reference.StatusCode -ne 201 -and $reference.Value.message -cne 'Reference already exists') { - throw "Cannot atomically claim release tag [$($Claim.ReleaseTag)] (HTTP $($reference.StatusCode))." + $claimSha = $current.VersionTagSha + if ($null -eq $claimSha) { + $claimSha = $current.TagSha } - $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader - if ($null -eq $current.TagSha -or - ($reference.StatusCode -eq 201 -and $current.TagSha -cne $tag.sha)) { - throw 'The publication tag changed during the create-only claim. Refusing Gallery publication.' + if ($null -eq $claimSha) { + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($Claim | ConvertTo-Json -Compress))) + $message = "" + $tagFields = @{ + tag = $Claim.ReleaseTag + message = $message + object = $Claim.CommitSha + type = 'commit' + } + $tag = (Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/tags" ` + -Method POST -Field $tagFields).Value + if ($tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -cne $Claim.ReleaseTag -or + $tag.object.type -cne 'commit' -or $tag.object.sha -cne $Claim.CommitSha -or $tag.message -cne $message) { + throw 'The annotated object did not preserve the exact source/context/artifact claim.' + } + $claimSha = $tag.sha + } + if ($null -eq $current.VersionTagSha) { + $versionTag = Get-PSModulePublicationVersionTag -ModuleName $Claim.ModuleName -GalleryVersion $Claim.GalleryVersion + $reference = Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/refs" ` + -Method POST -Field @{ ref = "refs/tags/$versionTag"; sha = $claimSha } -AllowConflict + if ($reference.StatusCode -ne 201 -and $reference.Value.message -cne 'Reference already exists') { + throw "Cannot atomically reserve module/version [$versionTag] (HTTP $($reference.StatusCode))." + } + $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($null -eq $current.VersionTagSha -or + ($reference.StatusCode -eq 201 -and $current.VersionTagSha -cne $claimSha)) { + throw 'The module/version coordinate changed during its create-only claim. Refusing publication.' + } + $claimSha = $current.VersionTagSha + } + if ($null -eq $current.TagSha) { + $reference = Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/refs" ` + -Method POST -Field @{ ref = "refs/tags/$($Claim.ReleaseTag)"; sha = $claimSha } -AllowConflict + if ($reference.StatusCode -ne 201 -and $reference.Value.message -cne 'Reference already exists') { + throw "Cannot atomically create release tag [$($Claim.ReleaseTag)] (HTTP $($reference.StatusCode))." + } + } + $verified = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($verified.TagSha -cne $claimSha -or $verified.VersionTagSha -cne $claimSha) { + throw 'The release tag or shared module/version coordinate changed before publication.' } - $current + $verified } diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 9f1a5ada..eac6ad8e 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -45,6 +45,7 @@ LogGroup 'Load inputs' { $psGalleryApiKey = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY $whatIf = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf -eq 'true' $releaseTag = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag + $versionPrefix = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix $sourceCommit = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha if ([string]::IsNullOrWhiteSpace($sourceCommit)) { $sourceCommit = $env:GITHUB_SHA @@ -57,6 +58,7 @@ LogGroup 'Load inputs' { } Write-Host "Module name: [$name]" + Write-Host "Version prefix: [$versionPrefix]" Write-Host "Module path: [$modulePath]" Write-Host "WhatIf: [$whatIf]" } @@ -169,6 +171,7 @@ try { CommitSha = $sourceCommit ModuleName = $name GalleryVersion = $publishPSVersion + VersionPrefix = $versionPrefix ReleaseTag = $releaseTag ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } ArtifactSha256 = $artifactDigest @@ -195,6 +198,7 @@ try { $publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader if ($currentState.TagSha -cne $publicationState.TagSha -or + $currentState.VersionTagSha -cne $publicationState.VersionTagSha -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The publication claim or tested artifact changed before the Gallery write. Refusing publication.' } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 2078fccd..a80948e6 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -29,6 +29,7 @@ BeforeAll { 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix' 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' ) @@ -63,7 +64,8 @@ AfterAll { Describe 'Publish-PSModule source-bound preflight' { BeforeEach { - $script:modulePath = Join-Path -Path $TestDrive -ChildPath 'workspace' -AdditionalChildPath '.PSModule', 'module', 'TestModule' + $workspaceName = "workspace-$([guid]::NewGuid())" + $script:modulePath = Join-Path -Path $TestDrive -ChildPath $workspaceName -AdditionalChildPath '.PSModule', 'module', 'TestModule' $null = New-Item -Path $script:modulePath -ItemType Directory -Force Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1') -Value '# Tested bytes.' Set-Content -LiteralPath (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psd1') -Value @' @@ -86,12 +88,13 @@ Describe 'Publish-PSModule source-bound preflight' { $env:GITHUB_EVENT_PATH = $eventPath $env:GITHUB_REPOSITORY = 'PSModule/TestModule' $env:GITHUB_SHA = '1111111111111111111111111111111111111111' - $env:GITHUB_WORKSPACE = Join-Path -Path $TestDrive -ChildPath 'workspace' + $env:GITHUB_WORKSPACE = Join-Path -Path $TestDrive -ChildPath $workspaceName $env:PSMODULE_PUBLISH_PSMODULE_INPUT_Name = 'TestModule' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath = '.PSModule/module' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'anonymous-fixture' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix = 'v' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' @@ -331,6 +334,7 @@ Describe 'Publish-PSModule source-bound preflight' { It 'rejects an unreadable final ownership footer on a stable release' -ForEach @( @{ Footer = '' } @{ Footer = '' } + @{ Footer = "" } ) { Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $script:state.Release = [pscustomobject]@{ @@ -390,4 +394,90 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GalleryWrites | Should -Be 0 $script:state.GitHubWrites | Should -Be 0 } + + It 'rejects a competing module/version claim under another prefix with identical bytes' { + $foreign = $script:state.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.CommitSha = '2222222222222222222222222222222222222222' + $foreign.PullRequest = 99 + $foreign.VersionPrefix = '' + $foreign.ReleaseTag = '1.2.4' + Set-PSModuleFixtureClaim -State $script:state -Claim $foreign -TagSha '4444444444444444444444444444444444444444' + $script:state.Tag = $null + $script:state.TagObject = $null + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*module/version*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects another prefix claiming the package coordinate concurrently before Gallery' { + $script:state.OnVersionTagCreate = { + param($State) + $foreign = $State.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.CommitSha = '2222222222222222222222222222222222222222' + $foreign.VersionPrefix = '' + $foreign.ReleaseTag = '1.2.4' + Set-PSModuleFixtureClaim -State $State -Claim $foreign -TagSha '4444444444444444444444444444444444444444' + $State.Tag = $null + $State.TagObject = $null + } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*module/version*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubCreationUpload | Should -Be 0 + } + + It 'does not fabricate a shared coordinate for an already-published package' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.VersionTag = $null + $script:state.VersionTagObject = $null + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*module/version ownership proof*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects a tag bound to another artifact version before any claim mutation' { + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.5' + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*tag*version*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'preserves an existing custom prefix while reserving the shared package coordinate' { + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix = 'release-' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'release-1.2.4' + $script:state.ExpectedClaim.VersionPrefix = 'release-' + $script:state.ExpectedClaim.ReleaseTag = 'release-1.2.4' + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.VersionTag.sha | Should -BeExactly $script:state.Tag.sha + $script:state.Calls.IndexOf('module-version-ref-create') | + Should -BeLessThan $script:state.Calls.IndexOf('tag-ref-create') + } + + It 'rejects NuGet-reserved tested paths before a claim or upload' -ForEach @( + @{ Components = @('extra.nuspec') } + @{ Components = @('[Content_Types].xml') } + @{ Components = @('_rels', '.rels') } + @{ Components = @('package', 'services', 'metadata', 'core-properties', 'abc.psmdcp') } + ) { + $path = $script:modulePath + foreach ($component in $Components) { $path = [IO.Path]::Combine($path, $component) } + $null = New-Item -Path (Split-Path -Path $path -Parent) -ItemType Directory -Force + [IO.File]::WriteAllText($path, 'Reserved anonymous artifact bytes.') + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*NuGet-reserved*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 index dc35a512..cfb306d4 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -24,6 +24,7 @@ BeforeAll { 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix' 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' ) @@ -82,6 +83,7 @@ Describe 'Publish-PSModule recovery' { $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'test-key' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix = 'v' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' $script:state = New-PSModulePublicationFixture -Path $script:modulePath -Version '1.2.4' -Tag 'v1.2.4' diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 index 5b102f99..47cb08be 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 @@ -121,6 +121,8 @@ function Set-PSModuleFixtureClaim { message = "" object = @{ type = 'commit'; sha = $Claim.CommitSha } } + $State.VersionTag = $State.Tag + $State.VersionTagObject = $State.TagObject } function New-PSModulePublicationFixture { @@ -168,7 +170,11 @@ function New-PSModulePublicationFixture { # The immutable build source used by the anonymous fixture. [Parameter()] - [string] $CommitSha = '1111111111111111111111111111111111111111' + [string] $CommitSha = '1111111111111111111111111111111111111111', + + # The existing resolver prefix used by this fixture. + [Parameter()] + [string] $VersionPrefix = 'v' ) if (-not $PSCmdlet.ShouldProcess($Path, 'Install local publication command replacements')) { @@ -191,6 +197,8 @@ function New-PSModulePublicationFixture { GitHubCreationUpload = 0 Tag = $null TagObject = $null + VersionTag = $null + VersionTagObject = $null NewTagObject = $null Release = $null Package = $null @@ -199,6 +207,7 @@ function New-PSModulePublicationFixture { TagReads = 0 OnTagRead = $null OnTagCreate = $null + OnVersionTagCreate = $null OnGeneratedNotes = $null FailReleaseCreate = $false ExpectedClaim = [pscustomobject]@{ @@ -207,6 +216,7 @@ function New-PSModulePublicationFixture { CommitSha = $CommitSha ModuleName = $name GalleryVersion = $Version + VersionPrefix = $VersionPrefix ReleaseTag = $Tag ReleaseType = if ($Version.Contains('-')) { 'Prerelease' } else { 'Release' } ArtifactSha256 = Get-PSModuleArtifactDigest -Path $Path @@ -298,18 +308,33 @@ function New-PSModulePublicationFixture { $state.NewTagObject = $value $status = 201 } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/refs") { - $state.Calls.Add('tag-ref-create') - if ($null -ne $state.OnTagCreate) { - & $state.OnTagCreate $state + $versionTagName = Get-PSModulePublicationVersionTag -ModuleName $claim.ModuleName -GalleryVersion $claim.GalleryVersion + $isVersionTag = $fields.ref -ceq "refs/tags/$versionTagName" + if ($isVersionTag) { + $state.Calls.Add('module-version-ref-create') + if ($null -ne $state.OnVersionTagCreate) { & $state.OnVersionTagCreate $state } + $existing = $state.VersionTag + } else { + $state.Calls.Add('tag-ref-create') + if ($null -ne $state.OnTagCreate) { & $state.OnTagCreate $state } + $existing = $state.Tag } - if ($null -ne $state.Tag) { + if ($null -ne $existing) { $status = 422 $global:LASTEXITCODE = 1 $value = @{ message = 'Reference already exists' } } else { - $state.Tag = @{ type = 'tag'; sha = $fields.sha } - $state.TagObject = $state.NewTagObject - $value = @{ ref = $fields.ref; object = $state.Tag } + $referenceObject = @{ type = 'tag'; sha = $fields.sha } + if ($isVersionTag) { + $state.VersionTag = $referenceObject + $state.VersionTagObject = if ($null -ne $state.NewTagObject) { + $state.NewTagObject + } else { $state.TagObject } + } else { + $state.Tag = $referenceObject + $state.TagObject = $state.VersionTagObject + } + $value = @{ ref = $fields.ref; object = $referenceObject } $status = 201 } } elseif ($endpoint -ceq "repos/$($claim.Repository)/releases/generate-notes") { @@ -325,15 +350,26 @@ function New-PSModulePublicationFixture { } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/commits/$($claim.CommitSha)") { $value = @{ sha = $claim.CommitSha } } elseif ($endpoint -like '*/git/ref/tags/*') { - $state.TagReads++ - if ($null -ne $state.OnTagRead) { - & $state.OnTagRead $state - } - if ($null -ne $state.Tag) { - $value = @{ ref = "refs/tags/$($claim.ReleaseTag)"; object = $state.Tag } + $tagName = [Uri]::UnescapeDataString(($endpoint -split '/git/ref/tags/', 2)[1]) + $versionTagName = Get-PSModulePublicationVersionTag -ModuleName $claim.ModuleName -GalleryVersion $claim.GalleryVersion + if ($tagName -ceq $versionTagName) { + if ($null -ne $state.VersionTag) { + $value = @{ ref = "refs/tags/$versionTagName"; object = $state.VersionTag } + } + } else { + $state.TagReads++ + if ($null -ne $state.OnTagRead) { & $state.OnTagRead $state } + if ($null -ne $state.Tag) { + $value = @{ ref = "refs/tags/$($claim.ReleaseTag)"; object = $state.Tag } + } } } elseif ($endpoint -like '*/git/tags/*') { - $value = $state.TagObject + $sha = ($endpoint -split '/git/tags/', 2)[1] + if ($null -ne $state.VersionTag -and $sha -ceq $state.VersionTag.sha) { + $value = $state.VersionTagObject + } else { + $value = $state.TagObject + } } elseif ($endpoint -like '*/releases/tags/*') { $value = $state.Release } else { diff --git a/.github/actions/Release-PSModule/action.yml b/.github/actions/Release-PSModule/action.yml index 33789546..caee4156 100644 --- a/.github/actions/Release-PSModule/action.yml +++ b/.github/actions/Release-PSModule/action.yml @@ -37,6 +37,10 @@ inputs: ReleaseTag: description: Full GitHub release tag resolved by Resolve-PSModuleVersion. required: true + VersionPrefix: + description: Configured resolver prefix used to verify the tag against the tested artifact version. + required: false + default: v PullRequest: description: Normalized pull request context JSON from Get-PSModuleSettings. required: false @@ -78,6 +82,7 @@ runs: PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag: ${{ inputs.ReleaseTag }} + PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix: ${{ inputs.VersionPrefix }} PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest: ${{ inputs.PullRequest }} PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha: ${{ inputs.CommitSha || github.sha }} run: ${{ github.action_path }}/src/release.ps1 diff --git a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 index dbc70f5f..670a8931 100644 --- a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 +++ b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 @@ -99,7 +99,7 @@ function Get-PSModulePrereleaseOwnership { $match = [regex]::Match($Body, '(?:^|\r?\n)\s*\z') if (-not $match.Success) { - if ($Body -match '(?:^|\r?\n)).)*(?:-->)?\s*\z') { throw 'The published ownership footer is malformed or uses an unsupported provenance version.' } return @@ -271,7 +271,8 @@ function Get-PSModulePublicationClaim { -not [long]::TryParse([string]$claim.PullRequest, [ref]$number) -or $number -lt 0 -or $claim.CommitSha -notmatch '^[0-9a-f]{40}$' -or $claim.ModuleName -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$' -or - $claim.GalleryVersion -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' -or + $claim.GalleryVersion -notmatch '^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' -or + $claim.VersionPrefix -isnot [string] -or [string]::IsNullOrWhiteSpace($claim.ReleaseTag) -or $claim.ReleaseTag -match '[\s\x00-\x1f\x7f]' -or $claim.ReleaseTag -match '(^|/)\.|[~^:?*\[\\]|\.\.|@\{|//|/$|\.lock(?:/|$)|\.$' -or @@ -281,6 +282,9 @@ function Get-PSModulePublicationClaim { $claim.AssetName -cne "$($claim.ModuleName)-$($claim.GalleryVersion).zip") { throw 'The publication claim has invalid source, release context, coordinates, or artifact proof.' } + if ($claim.ReleaseTag -cne "$($claim.VersionPrefix)$($claim.GalleryVersion)") { + throw 'The publication claim release tag does not bind the configured prefix and tested module version.' + } $isPrerelease = $claim.GalleryVersion.Contains('-') if (($claim.ReleaseType -ceq 'Prerelease') -ne $isPrerelease) { throw 'The publication claim release type disagrees with its resolved module version.' @@ -291,6 +295,7 @@ function Get-PSModulePublicationClaim { CommitSha = [string]$claim.CommitSha ModuleName = [string]$claim.ModuleName GalleryVersion = [string]$claim.GalleryVersion + VersionPrefix = [string]$claim.VersionPrefix ReleaseTag = [string]$claim.ReleaseTag ReleaseType = [string]$claim.ReleaseType ArtifactSha256 = ([string]$claim.ArtifactSha256).ToLowerInvariant() @@ -347,6 +352,9 @@ function Get-PSModuleArtifactFile { if ($relativePath.Contains('\')) { throw 'The tested artifact contains a literal filename backslash. Refusing to rename its published file tree.' } + if (Test-PSModulePackageMetadataPath -Path $relativePath) { + throw "The tested artifact contains NuGet-reserved metadata path [$relativePath]. Refusing publication." + } [pscustomobject]@{ RelativePath = $relativePath FullName = $file.FullName @@ -354,6 +362,77 @@ function Get-PSModuleArtifactFile { } } +function Test-PSModulePackageMetadataPath { + <# + .SYNOPSIS + Identify generated NuGet container paths excluded from module payload proof. + + .EXAMPLE + Test-PSModulePackageMetadataPath -Path 'package/services/metadata/core-properties/abc.psmdcp' + + Return true for a reserved package-metadata path. + + .INPUTS + None + + You can't pipe objects to Test-PSModulePackageMetadataPath. + + .OUTPUTS + System.Boolean + + Whether the path is generated NuGet container metadata. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + # The exact portable package-relative path. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path + ) + + $pattern = '^(?:[^/\\]+\.nuspec|\[Content_Types\]\.xml|_rels/\.rels|\.signature\.p7s|' + + 'package/services/metadata/core-properties/[0-9a-f]+\.psmdcp)$' + $Path -match $pattern +} + +function Get-PSModulePublicationVersionTag { + <# + .SYNOPSIS + Get the shared module/version claim coordinate independently of release-tag prefix. + + .EXAMPLE + Get-PSModulePublicationVersionTag -ModuleName TestModule -GalleryVersion 1.2.4 + + Return the immutable package-coordinate tag name. + + .INPUTS + None + + You can't pipe objects to Get-PSModulePublicationVersionTag. + + .OUTPUTS + System.String + + The shared module/version ownership tag name. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The exact Gallery module name. + [Parameter(Mandatory)] + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]*$')] + [string] $ModuleName, + + # The resolved canonical Gallery version. + [Parameter(Mandatory)] + [ValidatePattern('^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$')] + [string] $GalleryVersion + ) + + "PSModule-Publication/$($ModuleName.ToLowerInvariant())/$($GalleryVersion.ToLowerInvariant())" +} + function Get-PSModuleContentDigest { <# .SYNOPSIS @@ -627,9 +706,7 @@ function Get-PSModulePackageArtifactDigest { if ($relativePath.EndsWith('/')) { continue } - if ($relativePath -ceq $nuspecs[0].FullName -or $relativePath -ceq '[Content_Types].xml' -or - $relativePath -ceq '_rels/.rels' -or $relativePath -ceq '.signature.p7s' -or - $relativePath -cmatch '^package/services/metadata/core-properties/[0-9a-f]+\.psmdcp$') { + if (Test-PSModulePackageMetadataPath -Path $relativePath) { continue } $stream = $entry.Open() diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 63308c4e..faf66fe1 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -84,12 +84,14 @@ LogGroup 'Load inputs' { $usePRTitleAsReleaseName = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true' $usePRTitleAsNotesHeading = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true' $releaseTag = $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag + $versionPrefix = $env:PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix $commitSha = $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha if ([string]::IsNullOrWhiteSpace($releaseTag)) { throw 'ReleaseTag is required. Ensure Publish.Module.Resolution.FullVersion is passed from the Plan job.' } Write-Host "Module name: [$name]" + Write-Host "Version prefix: [$versionPrefix]" Write-Host "Module path: [$modulePath]" Write-Host "Release tag: [$releaseTag]" Write-Host "WhatIf: [$whatIf]" @@ -203,6 +205,7 @@ LogGroup 'Create GitHub release' { CommitSha = $sourceCommit ModuleName = $name GalleryVersion = $publishPSVersion + VersionPrefix = $versionPrefix ReleaseTag = $releaseTag ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } ArtifactSha256 = Get-PSModuleArtifactDigest -Path $modulePath @@ -297,6 +300,7 @@ LogGroup 'Create GitHub release' { } else { $beforeCreate = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader if ($beforeCreate.TagSha -cne $publicationState.TagSha -or + $beforeCreate.VersionTagSha -cne $publicationState.VersionTagSha -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The source claim or tested artifact changed before release creation. Refusing publication.' } @@ -323,6 +327,7 @@ LogGroup 'Create GitHub release' { } else { $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader if ($currentState.TagSha -cne $publicationState.TagSha -or $null -eq $currentState.Release -or + $currentState.VersionTagSha -cne $publicationState.VersionTagSha -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The release tag, release context, or tested artifact changed before upload. Refusing publication.' } diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 index 4ba2cd5e..0b807f1b 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -21,6 +21,7 @@ Describe 'Source-bound publication proof' { CommitSha = '1111111111111111111111111111111111111111' ModuleName = 'TestModule' GalleryVersion = '1.2.4' + VersionPrefix = 'release-' ReleaseTag = 'release-1.2.4' ReleaseType = 'Release' ArtifactSha256 = 'a' * 64 @@ -49,6 +50,15 @@ Describe 'Source-bound publication proof' { Get-PSModulePublicationClaim -Message 'Legacy tag message.' | Should -BeNullOrEmpty } + It 'ignores a completed authored ownership marker followed by ordinary notes' { + $owner = @{ Repository = 'PSModule/TestModule'; PullRequest = 42; CommitSha = 'original-source' } | + ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $body = "`nOrdinary authored notes follow." + + Get-PSModulePrereleaseOwnership -Body $body | Should -BeNullOrEmpty + } + It 'rejects malformed claim provenance explicitly' -ForEach @( @{ Message = '' } @{ Message = '' } diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index 1b30b36b..d37b85d8 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -30,6 +30,7 @@ BeforeAll { 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName' 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading' 'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix' 'PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest' 'PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha' ) @@ -106,6 +107,7 @@ Describe 'Release-PSModule WhatIf' { $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'true' $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix = 'v' $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = '' $script:state = New-PSModulePublicationFixture -Path $script:modulePath ` diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 0ac1a030..39e4664d 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -64,6 +64,7 @@ jobs: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} @@ -86,6 +87,7 @@ jobs: UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }} UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }} ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} From dc78ee268e63ef8a014020bd2f99f0d2739609c3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 18:45:53 +0100 Subject: [PATCH 12/21] Document shared package ownership and tested version binding --- .github/actions/Publish-PSModule/README.md | 6 +++++- .github/actions/Release-PSModule/README.md | 4 +++- docs/content/guides/versioning-and-releases.md | 8 ++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 253ef7a8..17f2e56b 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -2,7 +2,7 @@ Publishes a pre-versioned, tested PowerShell module artifact to the PowerShell Gallery after source-bound ownership preflight. GitHub Release creation is handled by the separate [Release-PSModule](../Release-PSModule/README.md) action. -The publisher verifies the original source, exact resolved tag/version, producing repository/PR or direct-release context, and tested bytes before an external versioned-artifact mutation. An absent version is reserved with a create-only annotated tag containing the file-tree and ZIP SHA-256 proof. Existing tags are never updated or deleted. +The publisher verifies the original source, exact resolved tag/version, producing repository/PR or direct-release context, and tested bytes before an external versioned-artifact mutation. An absent Gallery module/version is reserved at `PSModule-Publication//`, independently of the configured release-tag prefix. The shared coordinate and release tag reference the same immutable annotated source/context/file-tree/ZIP claim. Existing tags are never updated or deleted; the package ownership coordinate is retained for immutable Gallery proof. An existing Gallery package is retained only when its raw nupkg payload matches that proof and the tested artifact. Package code is never imported to establish ownership. Unknown legacy proof, foreign tags/releases/assets, changed claims, authentication errors, and API failures stop publication explicitly. @@ -15,6 +15,7 @@ An existing Gallery package is retained only when its raw nupkg payload matches | `ArtifactName` | Name of the module artifact to download. | No | `module` | | `PSGALLERY_API_KEY` | PowerShell Gallery API key. | Yes | N/A | | `ReleaseTag` | Exact tag already resolved as `Publish.Module.Resolution.FullVersion`. | Yes | N/A | +| `VersionPrefix` | Existing resolver prefix used to bind `ReleaseTag` to the tested module version. | No | `v` | | `CommitSha` | Immutable commit that produced the tested artifact. | No | Original workflow `GITHUB_SHA` | | `PullRequest` | Normalized producing-PR context; empty for a direct stable release. | No | Empty | | `WhatIf` | Logs publishing operations without publishing the module. | No | `false` | @@ -35,10 +36,13 @@ This action does not provide outputs. ArtifactName: module PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} ``` Provide `GH_TOKEN` with contents-write access scoped to the consumer repository. The reusable workflow supplies its existing GitHub App token and resolved context internally. +NuGet-reserved container metadata paths are rejected in the tested candidate before a claim or upload, using the same path classification as raw package verification. A published package without its shared module/version coordinate is unproven; the publisher does not fabricate that ownership record on retry. + Use [Release-PSModule](../Release-PSModule/README.md) in a separate step to create the GitHub release from the same source and tested artifact. A retry must retain the original coordinates and bytes; unproven legacy publication requires reviewed forward-only recovery, not numeric guessing, retagging, or metadata changes. diff --git a/.github/actions/Release-PSModule/README.md b/.github/actions/Release-PSModule/README.md index 9df4b52a..4b045012 100644 --- a/.github/actions/Release-PSModule/README.md +++ b/.github/actions/Release-PSModule/README.md @@ -10,6 +10,7 @@ Creates or resumes a GitHub release for a pre-versioned, tested PowerShell modul | `ModulePath` | Path containing the built `/` module directory. | No | `.PSModule/module` | | `ArtifactName` | Name of the module artifact to download. | No | `module` | | `ReleaseTag` | Full GitHub release tag resolved by `Resolve-PSModuleVersion`. | Yes | N/A | +| `VersionPrefix` | Existing configured resolver prefix; verifies tag/artifact version binding. | No | `v` | | `CommitSha` | Original immutable build source for stable and prerelease tags. | No | Original workflow `GITHUB_SHA` | | `PullRequest` | Normalized producing-PR context; empty for a direct stable release. | No | Empty | | `WhatIf` | Logs release operations without creating or uploading anything. | No | `false` | @@ -38,12 +39,13 @@ Pass the Plan job's resolved `FullVersion` as `ReleaseTag` so the configured tag ModulePath: .PSModule/module ArtifactName: module ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} ``` The action supports normalized pull-request context and direct stable releases. Provide repository-scoped contents-write `GH_TOKEN` access and the original tested artifact/source. Tags resolve to actual commits, including annotated tags; mutable branch names and release target hints are not ownership proof. -Release creation and upload recheck the tag, provenance, and artifact identity. A missing upload can resume. An existing asset is retained only when its published SHA-256 matches the exact local ZIP; differing, ambiguous, or unverifiable bytes stop the action. Uploads never use `--clobber`, and tags are never retargeted. +Release creation and upload recheck the release tag, shared module/version coordinate, provenance, and artifact identity. Both refs must bind the same immutable claim, regardless of tag prefix. A missing upload can resume. An existing asset is retained only when its published SHA-256 matches the exact local ZIP; differing, ambiguous, or unverifiable bytes stop the action. Uploads never use `--clobber`, and tags are never retargeted. Complete authored or generated prerelease notes precede a final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release. Legacy records without proof require reviewed forward-only recovery; the action does not fabricate provenance or alter consumer metadata. diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 8d36c6d5..60204212 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -77,12 +77,16 @@ Release names and notes can be generated from the pull request — see Before taking a Gallery version, the publisher verifies the immutable consumer source, the Plan job's exact resolved version/tag, the producing repository/PR or direct-release context, and the tested module bytes. -An absent version tag is reserved with a create-only annotated claim containing the tested file-tree and -release-ZIP SHA-256 digests. Existing tags are not updated or deleted. +The Gallery module/version is reserved at a prefix-independent `PSModule-Publication//` +coordinate, and the configured release tag references that same immutable annotated claim. It records the +source/context, tested file-tree and release-ZIP SHA-256 digests. The resolver's existing prefix must bind +the tag to the tested artifact version. Existing tags are not updated or deleted. Standalone tags, annotated tags, releases, provenance footers, module assets, and exact Gallery packages must agree with that claim. Raw nupkg files are read without importing package code. Verified absence is different from an authentication/API failure or unknown ownership proof; errors and unavailable proof stop publication. +NuGet-reserved container paths are rejected in the tested candidate before claiming or uploading, rather +than being discarded only when verifying a published package. The publisher rechecks the claim and tested files immediately before Gallery upload. GitHub release creation and upload repeat the ownership checks. Missing uploads may resume, identical published ZIP bytes are retained, From 6254d3442778d07a776a5c4be4adba4ca37ba571 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:18:29 +0100 Subject: [PATCH 13/21] Reproduce lost release tags during shared publication reservation Three isolated disappearance/replacement races each reach one Gallery spy write before the guard correction. Two partial-claim controls pass. No live publication is used. --- .../Publish-PSModule.Preflight.Tests.ps1 | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index a80948e6..9dfccfc2 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -364,6 +364,88 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 } + It 'rejects an observed tag that during the shared coordinate reservation' -ForEach @( + @{ Mutation = 'disappears' } + @{ Mutation = 'is replaced' } + ) { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.VersionTag = $null + $script:state.VersionTagObject = $null + $script:state.LateTagMutation = $Mutation + $script:state.OnVersionTagCreate = { + param($State) + if ($State.LateTagMutation -ceq 'disappears') { + $State.Tag = $null + } else { + Set-PSModuleFixtureClaim -State $State -Claim $State.ExpectedClaim ` + -TagSha '4444444444444444444444444444444444444444' + } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubCreationUpload | Should -Be 0 + $script:state.Calls | Should -Contain 'module-version-ref-create' + $script:state.Calls | Should -Not -Contain 'tag-ref-create' + $failure.Exception.Message | Should -Match 'tag.*changed|disappeared' + } + + It 'rejects losing a tag first observed after preflight during the shared coordinate reservation' { + $script:state.OnTagRead = { + param($State) + if ($State.TagReads -eq 2) { + Set-PSModuleFixtureClaim -State $State -Claim $State.ExpectedClaim + $State.VersionTag = $null + $State.VersionTagObject = $null + } + } + $script:state.OnVersionTagCreate = { + param($State) + $State.Tag = $null + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubCreationUpload | Should -Be 0 + $script:state.Calls | Should -Contain 'module-version-ref-create' + $script:state.Calls | Should -Not -Contain 'tag-ref-create' + $script:state.Tag | Should -BeNullOrEmpty + $failure.Exception.Message | Should -Match 'tag.*changed|disappeared' + } + + It 'reserves a missing shared coordinate without replacing an unchanged existing tag' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.VersionTag = $null + $script:state.VersionTagObject = $null + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.Tag.sha | Should -BeExactly '3333333333333333333333333333333333333333' + $script:state.VersionTag.sha | Should -BeExactly $script:state.Tag.sha + $script:state.Calls | Should -Contain 'module-version-ref-create' + $script:state.Calls | Should -Not -Contain 'tag-ref-create' + $script:state.Calls | Should -Not -Contain 'tag-object-create' + } + + It 'completes a partial shared claim when the release tag has never been observed' { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $script:state.Tag = $null + $script:state.TagObject = $null + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.Tag.sha | Should -BeExactly $script:state.VersionTag.sha + $script:state.Calls | Should -Contain 'tag-ref-create' + $script:state.Calls | Should -Not -Contain 'module-version-ref-create' + $script:state.Calls | Should -Not -Contain 'tag-object-create' + } + It 'rejects supplied PR context without a positive identity before all writes' -ForEach @( @{ Json = '{}' } @{ Json = '{"Number":0}' } From 474d0946a6bcf4112b097a84557c7c76465e84a6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:21:41 +0100 Subject: [PATCH 14/21] Reject observed tag changes during shared module version reservation Recheck the immediately preceding tag SHA after create-only reservation, including a tag first observed after preflight. Retain valid partial-claim completion without recreating a disappeared or replaced observed tag. --- .github/actions/Publish-PSModule/README.md | 2 ++ .../Publish-PSModule/src/Publish-PSModule.Helpers.psm1 | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 17f2e56b..0dd5b534 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -4,6 +4,8 @@ Publishes a pre-versioned, tested PowerShell module artifact to the PowerShell G The publisher verifies the original source, exact resolved tag/version, producing repository/PR or direct-release context, and tested bytes before an external versioned-artifact mutation. An absent Gallery module/version is reserved at `PSModule-Publication//`, independently of the configured release-tag prefix. The shared coordinate and release tag reference the same immutable annotated source/context/file-tree/ZIP claim. Existing tags are never updated or deleted; the package ownership coordinate is retained for immutable Gallery proof. +An observed release tag must remain unchanged through shared coordinate creation. If it disappears or is replaced during that reservation, the attempt fails instead of recreating it. A valid partial shared claim can still create an absent release tag that has never been observed in the attempt. + An existing Gallery package is retained only when its raw nupkg payload matches that proof and the tested artifact. Package code is never imported to establish ownership. Unknown legacy proof, foreign tags/releases/assets, changed claims, authentication errors, and API failures stop publication explicitly. ## Inputs diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index 870a27f2..dcad2277 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -623,12 +623,16 @@ function New-PSModulePublicationTag { } if ($null -eq $current.VersionTagSha) { $versionTag = Get-PSModulePublicationVersionTag -ModuleName $Claim.ModuleName -GalleryVersion $Claim.GalleryVersion + $observedTagSha = $current.TagSha $reference = Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/refs" ` -Method POST -Field @{ ref = "refs/tags/$versionTag"; sha = $claimSha } -AllowConflict if ($reference.StatusCode -ne 201 -and $reference.Value.message -cne 'Reference already exists') { throw "Cannot atomically reserve module/version [$versionTag] (HTTP $($reference.StatusCode))." } $current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader + if ($null -ne $observedTagSha -and $current.TagSha -cne $observedTagSha) { + throw 'The existing publication tag changed or disappeared during the shared module/version claim. Refusing publication.' + } if ($null -eq $current.VersionTagSha -or ($reference.StatusCode -eq 201 -and $current.VersionTagSha -cne $claimSha)) { throw 'The module/version coordinate changed during its create-only claim. Refusing publication.' From 2afb9294a6de5452ba0d9aba00c722529b0eece5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:54:48 +0100 Subject: [PATCH 15/21] Reproduce coerced provenance and inconsistent release archive proof Malformed outer and field arrays are accepted as ownership, and a mismatched ZIP payload reaches Gallery in the isolated real-controller fixture. Preserve raw shape and bind both byte digests before mutation. --- .../Publish-PSModule.Preflight.Tests.ps1 | 77 +++++++++++++++++++ .../Release-PSModule.Ownership.Tests.ps1 | 38 +++++++++ 2 files changed, 115 insertions(+) diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 9dfccfc2..26070515 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -290,6 +290,82 @@ Describe 'Publish-PSModule source-bound preflight' { $failure | Should -Not -BeNullOrEmpty } + It 'rejects a publication claim before all writes' -ForEach @( + @{ Shape = 'top-level array' } + @{ Shape = 'array-valued field' } + ) { + $claim = $script:state.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json + if ($Shape -ceq 'array-valued field') { + $claim.PullRequest = @(0) + } + $json = $claim | ConvertTo-Json -Compress + if ($Shape -ceq 'top-level array') { $json = "[$json]" } + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) + $script:state.TagObject.message = "" + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*publication*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects a release footer before all writes' -ForEach @( + @{ Shape = 'top-level array' } + @{ Shape = 'array-valued field' } + ) { + $script:state.ExpectedClaim.PullRequest = 42 + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '{"Number":42}' + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $owner = [pscustomobject]@{ + Repository = 'PSModule/TestModule'; PullRequest = 42; CommitSha = $env:GITHUB_SHA + } + if ($Shape -ceq 'array-valued field') { $owner.PullRequest = @(42) } + $json = $owner | ConvertTo-Json -Compress + if ($Shape -ceq 'top-level array') { $json = "[$json]" } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json)) + $script:state.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false; draft = $false; body = ""; assets = @() + } + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*footer*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'rejects a ZIP payload inconsistent with the tested file-tree claim before all writes' { + Mock -CommandName Get-FileHash -ParameterFilter { $LiteralPath -like '*.zip' } -MockWith { + $archive = [IO.Compression.ZipFile]::Open($LiteralPath, [IO.Compression.ZipArchiveMode]::Update) + try { + $archive.GetEntry('TestModule/TestModule.psm1').Delete() + $writer = [IO.StreamWriter]::new($archive.CreateEntry('TestModule/TestModule.psm1').Open()) + try { $writer.Write('# Different ZIP payload bytes.') } finally { $writer.Dispose() } + } finally { + $archive.Dispose() + } + $stream = [IO.File]::OpenRead($LiteralPath) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + [pscustomobject]@{ + Hash = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '') + } + } finally { + $sha256.Dispose() + $stream.Dispose() + } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + Should -Invoke -CommandName Get-FileHash -Times 1 -Exactly -ParameterFilter { $LiteralPath -like '*.zip' } + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'archive' + } + It 'rejects changed Gallery package bytes despite matching numeric version and source claim' { Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } @@ -450,6 +526,7 @@ Describe 'Publish-PSModule source-bound preflight' { @{ Json = '{}' } @{ Json = '{"Number":0}' } @{ Json = '{"Number":"unknown"}' } + @{ Json = '{"Number":[42]}' } @{ Json = '42' } @{ Json = '[]' } @{ Json = '[null]' } diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 index 0b807f1b..6eeebd7b 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -67,6 +67,44 @@ Describe 'Source-bound publication proof' { { Get-PSModulePublicationClaim -Message $Message } | Should -Throw '*publication*' } + It 'rejects an array-valued publication instead of coercing its proof' -ForEach @( + @{ Field = 'Repository' } + @{ Field = 'CommitSha' } + @{ Field = 'ModuleName' } + @{ Field = 'GalleryVersion' } + @{ Field = 'VersionPrefix' } + @{ Field = 'ReleaseTag' } + @{ Field = 'ReleaseType' } + @{ Field = 'ArtifactSha256' } + @{ Field = 'AssetName' } + @{ Field = 'AssetSha256' } + ) { + $script:claim[$Field] = @($script:claim[$Field]) + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + ($script:claim | ConvertTo-Json -Compress) + )) + + { Get-PSModulePublicationClaim -Message "" } | + Should -Throw '*publication*' + } + + It 'rejects an array-valued ownership instead of projecting a scalar' -ForEach @( + @{ Field = 'Repository' } + @{ Field = 'PullRequest' } + @{ Field = 'CommitSha' } + ) { + $owner = @{ + Repository = 'PSModule/TestModule'; PullRequest = 42; CommitSha = $script:claim.CommitSha + } + $owner[$Field] = @($owner[$Field]) + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes( + ($owner | ConvertTo-Json -Compress) + )) + + { Get-PSModulePrereleaseOwnership -Body "" } | + Should -Throw '*footer*' + } + It 'rejects a foreign PR footer even when its immutable source matches' { $owner = @{ Repository = 'PSModule/TestModule' From 131c30b1ec476717b140a5703673ada72a854bfb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 20:21:28 +0100 Subject: [PATCH 16/21] Reproduce original event context projection before publisher validation --- .../Publish-PSModule.Preflight.Tests.ps1 | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 26070515..112108aa 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -541,6 +541,31 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 } + It 'rejects original event PR context before projection' -ForEach @( + @{ EventJson = '{"pull_request":[{"number":42}]}' } + @{ EventJson = '{"pull_request":{"number":[42]}}' } + @{ EventJson = '[{"pull_request":{"number":42}}]' } + @{ EventJson = '{"pull_request":{}}' } + ) { + [IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, $EventJson) + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*context*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + + It 'retains the valid producing event PR number before projection' { + [IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, '{"pull_request":{"number":42}}') + $script:state.ExpectedClaim.PullRequest = 42 + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $claim = Get-PSModulePublicationClaim -Message $script:state.TagObject.message + $claim.PullRequest | Should -Be 42 + } + It 'rejects a draft release before Gallery publication rather than reporting completed publication' { Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $script:state.Release = [pscustomobject]@{ From e59ac33a8627e021ec89d380988d14cc5eae44fc Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:37:35 +0100 Subject: [PATCH 17/21] Reject projected provenance and inconsistent release archive claims Validate raw producing context and proof shapes before projection. Verify ZIP bytes and payload as one snapshot against the tested tree before claims, Gallery writes, release creation and upload. --- .github/actions/Publish-PSModule/README.md | 2 + .../actions/Publish-PSModule/src/publish.ps1 | 25 ++++- .../Publish-PSModule.Preflight.Tests.ps1 | 69 +++++++++++- .github/actions/Release-PSModule/README.md | 2 + .../src/Release-PSModule.Helpers.psm1 | 104 +++++++++++++++++- .../actions/Release-PSModule/src/release.ps1 | 38 +++++-- .../Release-PSModule.Ownership.Tests.ps1 | 25 +++++ 7 files changed, 250 insertions(+), 15 deletions(-) diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 0dd5b534..52d7dd1a 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -8,6 +8,8 @@ An observed release tag must remain unchanged through shared coordinate creation An existing Gallery package is retained only when its raw nupkg payload matches that proof and the tested artifact. Package code is never imported to establish ownership. Unknown legacy proof, foreign tags/releases/assets, changed claims, authentication errors, and API failures stop publication explicitly. +Provenance must contain one original JSON object with scalar proof fields; arrays are not projected into valid identities. The ZIP's exact bytes and canonical payload digest are verified together against the tested file tree before a claim or Gallery write. Original event PR context is validated before normalized fields are selected. + ## Inputs | Name | Description | Required | Default | diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index eac6ad8e..2d4808d2 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -72,10 +72,15 @@ LogGroup 'Load release context' { $pullRequest = ConvertFrom-Json -InputObject $pullRequestJson -NoEnumerate -ErrorAction Stop } else { $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH - $githubEvent = $githubEventJson | ConvertFrom-Json - if ($githubEvent.pull_request) { + $githubEvent = ConvertFrom-Json -InputObject $githubEventJson -NoEnumerate -ErrorAction Stop + if ($githubEvent -is [array] -or $githubEvent -isnot [PSCustomObject]) { + throw 'The original GitHub event context requires one event object.' + } + $eventPullRequest = $githubEvent.pull_request + $null = Get-PSModuleProducingPullRequestNumber -PullRequest $eventPullRequest + if ($null -ne $eventPullRequest) { $pullRequest = [pscustomobject]@{ - Number = $githubEvent.pull_request.number + Number = $eventPullRequest.number } } } @@ -178,6 +183,11 @@ try { AssetName = $zipFileName AssetSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() } + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name + if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256) { + throw 'The release archive does not match the tested file-tree and asset claim. Refusing publication.' + } $publicationReader = Get-PSModulePublicationReader -Repository $env:GITHUB_REPOSITORY $publicationState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader } @@ -195,10 +205,19 @@ try { Write-Host "WhatIf: Create-only annotated claim for [$releaseTag]; retain any existing published bytes." Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***" } else { + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name + if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256 -or + (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { + throw 'The tested artifact or release archive changed before the immutable claim. Refusing publication.' + } $publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name if ($currentState.TagSha -cne $publicationState.TagSha -or $currentState.VersionTagSha -cne $publicationState.VersionTagSha -or + $archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256 -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The publication claim or tested artifact changed before the Gallery write. Refusing publication.' } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 112108aa..492b90cd 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -18,8 +18,12 @@ BeforeAll { $script:publishScriptPath = [IO.Path]::GetFullPath( [IO.Path]::Combine($PSScriptRoot, '..', 'src', 'publish.ps1') ) + $script:releaseScriptPath = [IO.Path]::GetFullPath( + [IO.Path]::Combine($PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'release.ps1') + ) $script:environmentVariableNames = @( 'GITHUB_EVENT_PATH' + 'GITHUB_OUTPUT' 'GITHUB_REPOSITORY' 'GITHUB_REPOSITORY_NAME' 'GITHUB_SHA' @@ -32,6 +36,16 @@ BeforeAll { 'PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix' 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' + 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix' + 'PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha' + 'PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest' + 'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading' ) $script:originalEnvironment = @{} foreach ($name in $script:environmentVariableNames) { @@ -97,6 +111,18 @@ Describe 'Publish-PSModule source-bound preflight' { $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix = 'v' $env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = 'TestModule' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = '.PSModule/module' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix = 'v' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA + $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'false' + $env:GITHUB_OUTPUT = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath 'outputs' + $null = New-Item -Path $env:GITHUB_OUTPUT -ItemType File $script:state = New-PSModulePublicationFixture -Path $script:modulePath -Version '1.2.4' -Tag 'v1.2.4' } @@ -335,7 +361,10 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 } - It 'rejects a ZIP payload inconsistent with the tested file-tree claim before all writes' { + It 'rejects a ZIP payload inconsistent with the tested file-tree claim before all writes' -ForEach @( + @{ Controller = 'publisher' } + @{ Controller = 'release' } + ) { Mock -CommandName Get-FileHash -ParameterFilter { $LiteralPath -like '*.zip' } -MockWith { $archive = [IO.Compression.ZipFile]::Open($LiteralPath, [IO.Compression.ZipArchiveMode]::Update) try { @@ -357,8 +386,10 @@ Describe 'Publish-PSModule source-bound preflight' { } } + $controllerPath = $script:publishScriptPath + if ($Controller -ceq 'release') { $controllerPath = $script:releaseScriptPath } $failure = $null - try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + try { & $controllerPath 1>$null 6>$null } catch { $failure = $_ } Should -Invoke -CommandName Get-FileHash -Times 1 -Exactly -ParameterFilter { $LiteralPath -like '*.zip' } $script:state.GalleryWrites | Should -Be 0 @@ -366,6 +397,26 @@ Describe 'Publish-PSModule source-bound preflight' { $failure.Exception.Message | Should -Match 'archive' } + It 'rejects a changed artifact before reserving any immutable claim' -ForEach @( + @{ Controller = 'publisher' } + @{ Controller = 'release' } + ) { + $script:state.PayloadPath = Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1' + $script:state.OnTagRead = { + param($State) + if ($State.TagReads -eq 1) { + [IO.File]::WriteAllText($State.PayloadPath, '# Changed candidate after the byte snapshot.') + } + } + $controllerPath = $script:publishScriptPath + if ($Controller -ceq 'release') { $controllerPath = $script:releaseScriptPath } + + { & $controllerPath 1>$null 6>$null } | Should -Throw '*artifact*immutable claim*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + It 'rejects changed Gallery package bytes despite matching numeric version and source claim' { Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } @@ -566,6 +617,20 @@ Describe 'Publish-PSModule source-bound preflight' { $claim.PullRequest | Should -Be 42 } + It 'rejects original release event PR context before projection' -ForEach @( + @{ EventJson = '{"pull_request":[{"number":42}]}' } + @{ EventJson = '{"pull_request":{"number":[42]}}' } + @{ EventJson = '[{"pull_request":{"number":42}}]' } + @{ EventJson = '{"pull_request":{}}' } + ) { + [IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, $EventJson) + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*context*' + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + } + It 'rejects a draft release before Gallery publication rather than reporting completed publication' { Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim $script:state.Release = [pscustomobject]@{ diff --git a/.github/actions/Release-PSModule/README.md b/.github/actions/Release-PSModule/README.md index 4b045012..f4546c40 100644 --- a/.github/actions/Release-PSModule/README.md +++ b/.github/actions/Release-PSModule/README.md @@ -48,4 +48,6 @@ The action supports normalized pull-request context and direct stable releases. Release creation and upload recheck the release tag, shared module/version coordinate, provenance, and artifact identity. Both refs must bind the same immutable claim, regardless of tag prefix. A missing upload can resume. An existing asset is retained only when its published SHA-256 matches the exact local ZIP; differing, ambiguous, or unverifiable bytes stop the action. Uploads never use `--clobber`, and tags are never retargeted. +The local ZIP's exact bytes and payload digest are verified together against the tested file tree before claiming, creating, or uploading. Original event PR context and versioned provenance must have valid object/scalar shapes before normalization; arrays cannot supply publication ownership. + Complete authored or generated prerelease notes precede a final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release. Legacy records without proof require reviewed forward-only recovery; the action does not fabricate provenance or alter consumer metadata. diff --git a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 index 670a8931..7c2d55a4 100644 --- a/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 +++ b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 @@ -105,9 +105,11 @@ function Get-PSModulePrereleaseOwnership { return } $bytes = [Convert]::FromBase64String($match.Groups[1].Value) - $ownership = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop + $ownership = ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)) -NoEnumerate -ErrorAction Stop $number = 0L - if ([string]::IsNullOrWhiteSpace($ownership.Repository) -or + if ($ownership -is [array] -or $ownership -isnot [PSCustomObject] -or + $ownership.Repository -isnot [string] -or $ownership.CommitSha -isnot [string] -or + $ownership.PullRequest -is [array] -or [string]::IsNullOrWhiteSpace($ownership.Repository) -or $ownership.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or -not [long]::TryParse([string]$ownership.PullRequest, [ref]$number) -or $number -lt 1 -or [string]::IsNullOrWhiteSpace($ownership.CommitSha)) { @@ -155,6 +157,7 @@ function Get-PSModuleProducingPullRequestNumber { $number = 0L if ($PullRequest -is [array] -or ($PullRequest -isnot [PSCustomObject] -and $PullRequest -isnot [Collections.IDictionary]) -or + $PullRequest.Number -is [array] -or -not [long]::TryParse([string]$PullRequest.Number, [ref]$number) -or $number -lt 1) { throw 'Supplied pull request context requires a positive producing pull request number.' } @@ -265,9 +268,21 @@ function Get-PSModulePublicationClaim { return } $bytes = [Convert]::FromBase64String($match.Groups[1].Value) - $claim = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop + $claim = ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)) -NoEnumerate -ErrorAction Stop + if ($claim -is [array] -or $claim -isnot [PSCustomObject]) { + throw 'The publication claim requires one proof object, not a projected JSON value.' + } + foreach ($field in @( + 'Repository', 'CommitSha', 'ModuleName', 'GalleryVersion', 'VersionPrefix', + 'ReleaseTag', 'ReleaseType', 'ArtifactSha256', 'AssetName', 'AssetSha256' + )) { + if ($claim.$field -isnot [string]) { + throw "The publication claim requires a scalar string [$field]." + } + } $number = 0L if ($claim.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or + $claim.PullRequest -is [array] -or -not [long]::TryParse([string]$claim.PullRequest, [ref]$number) -or $number -lt 0 -or $claim.CommitSha -notmatch '^[0-9a-f]{40}$' -or $claim.ModuleName -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$' -or @@ -623,6 +638,89 @@ function New-PSModuleReleaseArchive { [IO.FileInfo]::new($DestinationPath) } +function Get-PSModuleReleaseArchiveProof { + <# + .SYNOPSIS + Read the release ZIP and its module payload as one byte-proof snapshot. + + .DESCRIPTION + Hashes the archive and exact payload records through the same open stream. + The payload uses the canonical file-tree digest without extracting or importing files. + + .EXAMPLE + Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName TestModule + + Return the archive and payload digests to verify against the tested claim. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleReleaseArchiveProof. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The paired archive SHA-256 and canonical payload SHA-256. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The exact local release ZIP. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + # The expected single module root inside the ZIP. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName + ) + + $stream = [IO.File]::OpenRead($Path) + $sha256 = [Security.Cryptography.SHA256]::Create() + $archive = $null + try { + $assetDigest = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() + $stream.Position = 0 + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Read, $true) + $prefix = "$ModuleName/" + $records = [Collections.Generic.List[object]]::new() + foreach ($entry in $archive.Entries) { + if (-not $entry.FullName.StartsWith($prefix, [StringComparison]::Ordinal)) { + throw 'The release archive contains a foreign or unverifiable module root.' + } + $relativePath = $entry.FullName.Substring($prefix.Length) + if ($entry.FullName.EndsWith('/')) { + continue + } + if (Test-PSModulePackageMetadataPath -Path $relativePath) { + throw 'The release archive contains a NuGet-reserved payload path.' + } + $entryStream = $entry.Open() + try { + $records.Add([pscustomobject]@{ + RelativePath = $relativePath + Length = $entry.Length + Hash = $sha256.ComputeHash($entryStream) + }) + } finally { + $entryStream.Dispose() + } + } + if ($records.Count -eq 0) { + throw 'The release archive contains no verifiable tested payload.' + } + [pscustomobject]@{ + ArtifactSha256 = Get-PSModuleContentDigest -File $records.ToArray() + AssetSha256 = $assetDigest + } + } finally { + if ($null -ne $archive) { $archive.Dispose() } + $sha256.Dispose() + $stream.Dispose() + } +} + function Get-PSModulePackageArtifactDigest { <# .SYNOPSIS diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index faf66fe1..f6b55e82 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -99,17 +99,24 @@ LogGroup 'Load inputs' { LogGroup 'Load release context' { $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH - $githubEvent = $githubEventJson | ConvertFrom-Json + $githubEvent = ConvertFrom-Json -InputObject $githubEventJson -NoEnumerate -ErrorAction Stop + if ($githubEvent -is [array] -or $githubEvent -isnot [PSCustomObject]) { + throw 'The original GitHub event context requires one event object.' + } $pullRequestJson = $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest $pullRequest = $null if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { $pullRequest = ConvertFrom-Json -InputObject $pullRequestJson -NoEnumerate -ErrorAction Stop - } elseif ($githubEvent.pull_request) { - $pullRequest = [pscustomobject]@{ - Number = $githubEvent.pull_request.number - Title = $githubEvent.pull_request.title - Body = $githubEvent.pull_request.body - HeadRef = $githubEvent.pull_request.head.ref + } else { + $eventPullRequest = $githubEvent.pull_request + $null = Get-PSModuleProducingPullRequestNumber -PullRequest $eventPullRequest + if ($null -ne $eventPullRequest) { + $pullRequest = [pscustomobject]@{ + Number = $eventPullRequest.number + Title = $eventPullRequest.title + Body = $eventPullRequest.body + HeadRef = $eventPullRequest.head.ref + } } } $prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest @@ -212,8 +219,19 @@ LogGroup 'Create GitHub release' { AssetName = $zipFileName AssetSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() } + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name + if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256) { + throw 'The release archive does not match the tested file-tree and asset claim. Refusing publication.' + } $publicationReader = Get-PSModulePublicationReader -Repository $env:GITHUB_REPOSITORY $publicationState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name + if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256 -or + (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { + throw 'The tested artifact or release archive changed before the immutable claim. Refusing publication.' + } $publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader $releaseExists = $null -ne $publicationState.Release if ($releaseExists) { @@ -299,8 +317,11 @@ LogGroup 'Create GitHub release' { $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" } else { $beforeCreate = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name if ($beforeCreate.TagSha -cne $publicationState.TagSha -or $beforeCreate.VersionTagSha -cne $publicationState.VersionTagSha -or + $archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256 -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The source claim or tested artifact changed before release creation. Refusing publication.' } @@ -326,8 +347,11 @@ LogGroup 'Create GitHub release' { Write-Host "WhatIf: gh release upload $releaseTag $zipPath --repo $env:GITHUB_REPOSITORY" } else { $currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader + $archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name if ($currentState.TagSha -cne $publicationState.TagSha -or $null -eq $currentState.Release -or $currentState.VersionTagSha -cne $publicationState.VersionTagSha -or + $archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or + $archiveProof.AssetSha256 -cne $claim.AssetSha256 -or (Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) { throw 'The release tag, release context, or tested artifact changed before upload. Refusing publication.' } diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 index 6eeebd7b..34d74c00 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -185,6 +185,31 @@ Describe 'Tested artifact identity' { Get-PSModuleArtifactDigest -Path $script:modulePath | Should -Not -BeExactly $original } + It 'reads the exact archive bytes and canonical payload as a paired proof' { + $archivePath = Join-Path -Path $TestDrive -ChildPath 'paired-proof.zip' + $expected = Get-PSModuleArtifactDigest -Path $script:modulePath + $null = New-PSModuleReleaseArchive -Path $script:modulePath -DestinationPath $archivePath + + $proof = Get-PSModuleReleaseArchiveProof -Path $archivePath -ModuleName 'TestModule' + + $proof.ArtifactSha256 | Should -BeExactly $expected + $proof.AssetSha256 | Should -BeExactly (Get-FileHash -LiteralPath $archivePath).Hash.ToLowerInvariant() + } + + It 'rejects an archive with a foreign module root rather than ignoring its payload' { + $archivePath = Join-Path -Path $TestDrive -ChildPath 'foreign-root.zip' + $null = New-PSModuleReleaseArchive -Path $script:modulePath -DestinationPath $archivePath + $archive = [IO.Compression.ZipFile]::Open($archivePath, [IO.Compression.ZipArchiveMode]::Update) + try { + $writer = [IO.StreamWriter]::new($archive.CreateEntry('OtherModule/data.txt').Open()) + try { $writer.Write('Foreign payload.') } finally { $writer.Dispose() } + } finally { + $archive.Dispose() + } + + { Get-PSModuleReleaseArchiveProof -Path $archivePath -ModuleName 'TestModule' } | Should -Throw '*foreign*' + } + It 'rejects a literal Unix filename backslash before hashing or creating an archive' -Skip:$IsWindows { [IO.File]::WriteAllText([IO.Path]::Combine($script:modulePath, 'data\value.txt'), 'Literal filename bytes.') $archivePath = Join-Path -Path $TestDrive -ChildPath 'rejected-backslash.zip' From 3faf9fb1315471a3b11f62c1856b914fd263dd19 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:02:07 +0100 Subject: [PATCH 18/21] Reproduce terminal provenance, raw metadata and module ref compatibility gaps Publication spies accept malformed terminal markers and projected API arrays; stable PR provenance is absent, empty verified arrays fail, and valid module names can form invalid Git refs. All experiments are isolated. --- .../Publish-PSModule.Preflight.Tests.ps1 | 107 ++++++++++++++++++ .../tests/Publish-PSModule.TestFixture.psm1 | 7 +- .../Release-PSModule.Ownership.Tests.ps1 | 13 +++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 492b90cd..a441f3d7 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -13,6 +13,7 @@ BeforeAll { throw 'Cannot acquire the process environment lock for action tests.' } Import-Module -Name 'PSModule' -Force + Import-Module -Name ([IO.Path]::Combine($PSScriptRoot, '..', 'src', 'Publish-PSModule.Helpers.psm1')) -Force Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Publish-PSModule.TestFixture.psm1') -Force $script:publishScriptPath = [IO.Path]::GetFullPath( @@ -178,6 +179,43 @@ Describe 'Publish-PSModule source-bound preflight' { $failure.Exception.Message | Should -Match '503|API|read|lookup' } + It 'rejects a malformed response before publication' -ForEach @( + @{ Resource = 'repository array' } + @{ Resource = 'commit array' } + @{ Resource = 'string write permission' } + ) { + if ($Resource -ceq 'commit array') { + $endpoint = "repos/PSModule/TestModule/git/commits/$env:GITHUB_SHA" + $script:state.ApiResponses[$endpoint] = @([pscustomobject]@{ sha = $env:GITHUB_SHA }) + } else { + $metadata = [pscustomobject]@{ + full_name = 'PSModule/TestModule'; permissions = [pscustomobject]@{ push = $true } + } + if ($Resource -ceq 'string write permission') { + $metadata.permissions.push = 'true' + $script:state.ApiResponses['repos/PSModule/TestModule'] = $metadata + } else { + $script:state.ApiResponses['repos/PSModule/TestModule'] = @($metadata) + } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'metadata|access|source' + } + + It 'retains a verified empty tag enumeration instead of treating it as an unknown JSON response' { + $script:state.ApiResponses['repos/PSModule/TestModule/git/matching-refs/tags/'] = @() + + $response = Invoke-PSModulePublicationApi -Endpoint 'repos/PSModule/TestModule/git/matching-refs/tags/' + + $response.Value -is [array] | Should -BeTrue + $response.Value.Count | Should -Be 0 + } + It 'rejects a Gallery-only package with no source and artifact proof' { $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } @@ -475,6 +513,75 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 } + It 'rejects a terminal ownership marker with before publication' -ForEach @( + @{ Shape = 'indentation' } + @{ Shape = 'inline placement' } + @{ Shape = 'missing delimiter spacing' } + ) { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $owner = [pscustomobject]@{ + Repository = 'Other/TestModule'; PullRequest = 99; CommitSha = $env:GITHUB_SHA + } | ConvertTo-Json -Compress + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner)) + $footer = "" + if ($Shape -ceq 'indentation') { + $body = "Notes.`n $footer" + } elseif ($Shape -ceq 'inline placement') { + $body = "Notes. $footer" + } else { + $body = $footer.Replace('\s*\z') if (-not $match.Success) { - if ($Body -match '(?s)(?:^|\r?\n)).)*(?:-->)?\s*\z') { + if ($Body -match '(?s)).)*PSModule\.PrereleaseOwner(?:(?!-->).)*(?:-->)?\s*\z') { throw 'The published ownership footer is malformed or uses an unsupported provenance version.' } return @@ -445,7 +451,8 @@ function Get-PSModulePublicationVersionTag { [string] $GalleryVersion ) - "PSModule-Publication/$($ModuleName.ToLowerInvariant())/$($GalleryVersion.ToLowerInvariant())" + $moduleCoordinate = $ModuleName.ToLowerInvariant().Replace('.', '%2e') + "PSModule-Publication/$moduleCoordinate/$($GalleryVersion.ToLowerInvariant())" } function Get-PSModuleContentDigest { diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index f6b55e82..0fa8998b 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -120,6 +120,11 @@ LogGroup 'Load release context' { } } $prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest + foreach ($field in @('Title', 'Body', 'HeadRef')) { + if ($null -ne $pullRequest.$field -and $pullRequest.$field -isnot [string]) { + throw "Supplied release context requires scalar string metadata for [$field]." + } + } $prHeadRef = $pullRequest.HeadRef $commitMessage = $githubEvent.head_commit.message if ([string]::IsNullOrWhiteSpace($commitMessage) -and -not [string]::IsNullOrWhiteSpace($commitSha)) { @@ -275,40 +280,40 @@ LogGroup 'Create GitHub release' { $releaseCreateCommand += @('--target', $sourceCommit) if ($createPrerelease) { $releaseCreateCommand += '--prerelease' - if ($prNumber) { - $ownership = [pscustomobject]@{ - Repository = $env:GITHUB_REPOSITORY - PullRequest = [long]$prNumber - CommitSha = $sourceCommit - } | ConvertTo-Json -Compress - $encodedOwnership = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ownership)) - $ownershipFooter = "" - if ($null -eq $notesFilePath) { - if ($whatIf) { - Write-Host ( - "WhatIf: gh api repos/$env:GITHUB_REPOSITORY/releases/generate-notes --method POST " + - "--field tag_name=$releaseTag --field target_commitish=$sourceCommit" - ) - $generatedBody = '' - } else { - $generatedNotes = gh api "repos/$env:GITHUB_REPOSITORY/releases/generate-notes" --method POST ` - --field "tag_name=$releaseTag" --field "target_commitish=$sourceCommit" - if ($LASTEXITCODE -ne 0) { - throw "Cannot generate prerelease notes (gh exited $LASTEXITCODE)." - } - $generated = $generatedNotes | ConvertFrom-Json -ErrorAction Stop - if ($generated.PSObject.Properties.Name -notcontains 'body') { - throw 'Generated release notes have no body. Refusing incomplete notes.' - } - $generatedBody = [string]$generated.body + } + if ($prNumber) { + $ownership = [pscustomobject]@{ + Repository = $env:GITHUB_REPOSITORY + PullRequest = [long]$prNumber + CommitSha = $sourceCommit + } | ConvertTo-Json -Compress + $encodedOwnership = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ownership)) + $ownershipFooter = "" + if ($null -eq $notesFilePath) { + if ($whatIf) { + Write-Host ( + "WhatIf: gh api repos/$env:GITHUB_REPOSITORY/releases/generate-notes --method POST " + + "--field tag_name=$releaseTag --field target_commitish=$sourceCommit" + ) + $generatedBody = '' + } else { + $generatedNotes = gh api "repos/$env:GITHUB_REPOSITORY/releases/generate-notes" --method POST ` + --field "tag_name=$releaseTag" --field "target_commitish=$sourceCommit" + if ($LASTEXITCODE -ne 0) { + throw "Cannot generate release notes (gh exited $LASTEXITCODE)." + } + $generated = ConvertFrom-Json -InputObject ($generatedNotes -join "`n") -NoEnumerate -ErrorAction Stop + if ($generated -isnot [PSCustomObject] -or $generated -is [array] -or $generated.body -isnot [string]) { + throw 'Generated release notes have no scalar body. Refusing incomplete notes.' } - $notesFilePath = [IO.Path]::GetTempFileName() - Set-Content -LiteralPath $notesFilePath -Value $generatedBody -Encoding utf8 - $releaseCreateCommand = @($releaseCreateCommand | Where-Object { $_ -ne '--generate-notes' }) - $releaseCreateCommand += @('--notes-file', $notesFilePath) + $generatedBody = $generated.body } - Add-Content -LiteralPath $notesFilePath -Value "`n$ownershipFooter" -Encoding utf8 + $notesFilePath = [IO.Path]::GetTempFileName() + Set-Content -LiteralPath $notesFilePath -Value $generatedBody -Encoding utf8 + $releaseCreateCommand = @($releaseCreateCommand | Where-Object { $_ -ne '--generate-notes' }) + $releaseCreateCommand += @('--notes-file', $notesFilePath) } + Add-Content -LiteralPath $notesFilePath -Value "`n$ownershipFooter" -Encoding utf8 } try { diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 60204212..72c312d0 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -77,11 +77,14 @@ Release names and notes can be generated from the pull request — see Before taking a Gallery version, the publisher verifies the immutable consumer source, the Plan job's exact resolved version/tag, the producing repository/PR or direct-release context, and the tested module bytes. -The Gallery module/version is reserved at a prefix-independent `PSModule-Publication//` +The Gallery module/version is reserved at a prefix-independent `PSModule-Publication//` coordinate, and the configured release tag references that same immutable annotated claim. It records the source/context, tested file-tree and release-ZIP SHA-256 digests. The resolver's existing prefix must bind the tag to the tested artifact version. Existing tags are not updated or deleted. +The module coordinate is lowercase with dots encoded as `%2e`, preserving valid module names without Git-invalid +`..` or `.lock` components. The original module name and consumer metadata are unchanged. + Standalone tags, annotated tags, releases, provenance footers, module assets, and exact Gallery packages must agree with that claim. Raw nupkg files are read without importing package code. Verified absence is different from an authentication/API failure or unknown ownership proof; errors and unavailable proof stop publication. @@ -90,8 +93,9 @@ than being discarded only when verifying a published package. The publisher rechecks the claim and tested files immediately before Gallery upload. GitHub release creation and upload repeat the ownership checks. Missing uploads may resume, identical published ZIP bytes are retained, -and differing or unverifiable assets are never clobbered. Complete authored or generated prerelease notes remain -before their final producing-repository/PR/source footer. +and differing or unverifiable assets are never clobbered. Complete authored or generated notes for stable and +prerelease PR releases remain before their final producing-repository/PR/source footer. Existing PR releases +without that proof cannot resume; indented, inline, and unreadable terminal markers fail explicitly. A retry retains the original source, resolved coordinates, release context, and tested bytes. Legacy incomplete publications without proof require reviewed forward-only recovery, not a guessed version, retagging, asset From bf242f28878f3f7aac0293f482991f1f800dbb5e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:56:42 +0100 Subject: [PATCH 20/21] Reproduce unverified draft metadata before publication --- .../Publish-PSModule.Preflight.Tests.ps1 | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index cd6ff591..2371e0e5 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -837,6 +837,34 @@ Describe 'Publish-PSModule source-bound preflight' { $script:state.GitHubWrites | Should -Be 0 } + It 'rejects draft metadata before Gallery or GitHub writes' -ForEach @( + @{ Shape = 'missing' } + @{ Shape = 'null' } + @{ Shape = 'string false' } + @{ Shape = 'array false' } + ) { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + $release = [pscustomobject]@{ + tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false; body = 'Direct release notes.'; assets = @() + } + if ($Shape -ceq 'null') { + $release | Add-Member -NotePropertyName draft -NotePropertyValue $null + } elseif ($Shape -ceq 'string false') { + $release | Add-Member -NotePropertyName draft -NotePropertyValue 'false' + } elseif ($Shape -ceq 'array false') { + $release | Add-Member -NotePropertyName draft -NotePropertyValue @($false) + } + $script:state.ApiResponses['repos/PSModule/TestModule/releases/tags/v1.2.4'] = $release + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'draft|published' + } + It 'rejects a competing module/version claim under another prefix with identical bytes' { $foreign = $script:state.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json $foreign.CommitSha = '2222222222222222222222222222222222222222' From eb3eb0cb8fb6f0b7822c79ad9b2781d17127dcd4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 1 Nov 2026 00:00:23 +0100 Subject: [PATCH 21/21] Require explicit published release status before publication writes Reject missing or malformed draft metadata and retain valid published controls. The branch-specific Release WhatIf fixture handback adds only two boolean fields and changes no assertions. --- .github/actions/Publish-PSModule/README.md | 2 ++ .../Publish-PSModule/src/Publish-PSModule.Helpers.psm1 | 4 ++-- .../tests/Publish-PSModule.Preflight.Tests.ps1 | 2 ++ .../Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 | 2 ++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 4a737396..e685d4ad 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -12,6 +12,8 @@ Provenance must contain one original JSON object with scalar proof fields; array GitHub resource responses must have valid object/scalar shapes, while verified empty tag collections remain valid absence. An existing PR-produced stable or prerelease release requires its producing-context footer; unreadable terminal markers are errors even when indented or inline. Unknown legacy PR release context is not inferred from a matching tag. +Existing releases must explicitly return scalar boolean `draft: false`. Missing, null, string, or array status is unknown published-release proof and stops before Gallery publication or a resumed asset upload. + ## Inputs | Name | Description | Required | Default | diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index a69790fa..9479ccc0 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -506,8 +506,8 @@ function Get-PSModulePublicationState { } } if ($null -ne $release) { - if ($release.draft -eq $true) { - throw 'The existing release is a draft. Refusing Gallery publication or a success-shaped incomplete release.' + if ($release.draft -isnot [bool] -or $release.draft -ne $false) { + throw 'The existing release has no verified boolean draft:false status. Refusing publication or an incomplete release.' } if ($release.tag_name -cne $expected.ReleaseTag -or [string]::IsNullOrWhiteSpace($release.html_url) -or diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 index 2371e0e5..1a1e13e8 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -155,6 +155,7 @@ Describe 'Publish-PSModule source-bound preflight' { tag_name = 'v1.2.4' html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' prerelease = $false + draft = $false assets = @($asset) } @@ -511,6 +512,7 @@ Describe 'Publish-PSModule source-bound preflight' { tag_name = 'v1.2.4' html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' prerelease = $false + draft = $false body = "Notes.`n" assets = @() } diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index d37b85d8..f702f17a 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -132,6 +132,7 @@ Describe 'Release-PSModule WhatIf' { tag_name = 'v1.2.3-preview.1' html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' prerelease = $true + draft = $false body = "Original notes.`n" assets = @() } @@ -213,6 +214,7 @@ Describe 'Release-PSModule WhatIf' { tag_name = 'v1.2.3-preview.1' html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' prerelease = $true + draft = $false body = "Foreign PR notes.`n" assets = @() }