From 3eba87855c43f7c3cd398a4b9c04f8e545e3ad36 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:40:22 +0100 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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 857795a659b70139d1243d6cbfeac9cfd5ec097e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 17:09:56 +0100 Subject: [PATCH 08/27] Select Gallery recovery versions only from immutable source and raw package proof --- .../src/Publish-PSModule.Helpers.psm1 | 5 +- .../src/Resolve-PSModuleVersion.Helpers.psm1 | 218 ++++++++++++++++-- .../Resolve-PSModuleVersion/src/main.ps1 | 23 +- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 12 +- ...Resolve-PSModuleVersion.Recovery.Tests.ps1 | 210 +++++++++++++++++ 5 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 .github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index 53e1cc9b..595891c9 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -128,7 +128,7 @@ function Invoke-PSModulePublicationApi { 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 + $value = $body | ConvertFrom-Json -NoEnumerate -ErrorAction Stop if ($null -eq $value) { throw "The GitHub API response for [$Endpoint] returned no resource metadata." } @@ -330,6 +330,9 @@ function Get-PSModulePublicationReader { } $response.Value.object }.GetNewClosure() + GetTagList = { + (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/matching-refs/tags/").Value + }.GetNewClosure() GetTagObject = { param($Sha) $response = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/tags/$Sha").Value diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index dccdef34..ea93f42d 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -1,5 +1,12 @@ $ErrorActionPreference = 'Stop' +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Publish-PSModule', 'src', 'Publish-PSModule.Helpers.psm1' + )) -Force +Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force + function Split-CommaSeparatedList { <# .SYNOPSIS @@ -726,17 +733,167 @@ function Get-NextModuleVersion { } } +function Get-PSModuleGalleryRecoveryVersion { + <# + .SYNOPSIS + Recover a stable version only from exact source/context and raw package proof. + + .DESCRIPTION + Searches actual version-tag claims, not latest hints or numeric equality. + The canonical preflight validates the original package/release evidence before Build. + A claimed source without package proof, conflicting context, or multiple versions fails. + + .EXAMPLE + Get-PSModuleGalleryRecoveryVersion -Context $context -Configuration $config -ModuleName $name ` + -PSGalleryVersion $galleryVersion -Reader $reader + + Return the original owned version or no recovery when the published baseline is complete. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleGalleryRecoveryVersion. + + .OUTPUTS + System.Object + + The proven original PSSemVer version, when recoverable. + #> + [CmdletBinding()] + [OutputType([object])] + param( + # The immutable consumer repository, producing PR/direct context, and source. + [Parameter(Mandatory)] + [PSCustomObject] $Context, + + # The existing configured version prefix; no new decision policy is introduced. + [Parameter(Mandatory)] + [PSCustomObject] $Configuration, + + # The module whose original package is being recovered. + [Parameter(Mandatory)] + [string] $ModuleName, + + # The latest observed stable Gallery version, used only to reject incomplete baselines. + [Parameter(Mandatory)] + [object] $PSGalleryVersion, + + # Read-only repository/tag/release/raw-package boundaries. + [Parameter(Mandatory)] + [hashtable] $Reader + ) + + $number = 0L + if ($Context.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or + $Context.CommitSha -notmatch '^[0-9a-f]{40}$' -or + -not [long]::TryParse([string]$Context.PullRequest, [ref]$number) -or $number -lt 0) { + throw 'Immutable repository/PR/source context is required for Gallery recovery proof.' + } + foreach ($name in @('GetSource', 'GetTagList', 'GetTagReference', 'GetTagObject', 'GetRelease', 'GetPackage', 'GetPackageArtifactDigest')) { + if ($Reader[$name] -isnot [scriptblock]) { + throw "The immutable publication reader [$name] is required for Gallery recovery proof." + } + } + $source = & $Reader.GetSource $Context.CommitSha + if ($source -cne $Context.CommitSha) { + throw 'The original consumer source could not be verified before Build.' + } + $owned = [Collections.Generic.List[object]]::new() + $references = @(& $Reader.GetTagList) + $prefix = [string]$Configuration.VersionPrefix + foreach ($reference in $references) { + if ($reference.ref -isnot [string] -or -not $reference.ref.StartsWith('refs/tags/') -or + $null -eq $reference.object) { + throw 'The publication tag enumeration returned unverifiable reference metadata.' + } + $tag = $reference.ref.Substring('refs/tags/'.Length) + if (-not $tag.StartsWith($prefix, [StringComparison]::Ordinal) -or + $tag.Substring($prefix.Length) -notmatch '^\d+\.\d+\.\d+$') { + continue + } + $evidence = @{ Annotation = $null; Reference = $null } + $parameters = @{ + Tag = $tag + GetTagReference = { + param($Tag) + $evidence.Reference = & $Reader.GetTagReference $Tag + $evidence.Reference + }.GetNewClosure() + GetTagObject = { + param($Sha) + $annotation = & $Reader.GetTagObject $Sha + if ($null -eq $evidence.Annotation) { $evidence.Annotation = $annotation } + $annotation.object + }.GetNewClosure() + } + $tagCommit = Resolve-PSModuleReleaseTagCommit @parameters + if ($tagCommit -cne $Context.CommitSha) { + continue + } + $claim = Get-PSModulePublicationClaim -Message $evidence.Annotation.message + if ($null -eq $claim) { + throw "Existing source-bound tag [$tag] has no verifiable artifact/release-context proof. Do not invent a fallback version." + } + if ($claim.Repository -ine $Context.Repository -or $claim.PullRequest -ne $number -or + $claim.CommitSha -cne $Context.CommitSha -or $claim.ModuleName -cne $ModuleName -or + $claim.ReleaseType -cne 'Release' -or $claim.ReleaseTag -cne $tag -or + "$prefix$($claim.GalleryVersion)" -cne $tag) { + throw "The source-bound tag [$tag] belongs to a different module or release context." + } + $state = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $Reader + if ($state.TagSha -cne $evidence.Reference.sha) { + throw "Publication claim [$tag] changed while verifying recovery proof. Refusing version resolution." + } + if ($null -eq $state.Package) { + throw ("The original source claim [$tag] has no published raw package proof. " + + 'Retain its resolved version/artifact and rerun the original job.') + } + $owned.Add($claim) + } + if ($owned.Count -gt 1) { + throw 'Multiple published versions claim this exact source and release context. Refusing ambiguous recovery.' + } + if ($owned.Count -eq 1) { + $version = New-PSSemVer -Version $owned[0].GalleryVersion + $version.Prefix = $prefix + Write-Verbose "Recovering proven original publication [$($version.ToString())] before Build." + return $version + } + if ($PSGalleryVersion.ToString() -ne '0.0.0') { + $galleryVersion = "$($PSGalleryVersion.Major).$($PSGalleryVersion.Minor).$($PSGalleryVersion.Patch)" + $tag = "$prefix$galleryVersion" + $release = & $Reader.GetRelease $tag + $assets = @($release.assets | Where-Object { + $_.name -ceq "$ModuleName-$galleryVersion.zip" -and $_.state -ceq 'uploaded' + }) + if ($null -eq $release -or $release.tag_name -cne $tag -or $release.prerelease -ne $false -or + $release.draft -ne $false -or $assets.Count -ne 1) { + throw "Gallery-only package [$galleryVersion] has no proven source/artifact recovery context. Do not invent a fallback version." + } + $tagParameters = @{ + Tag = $tag + GetTagReference = $Reader.GetTagReference + GetTagObject = { + param($Sha) + (& $Reader.GetTagObject $Sha).object + }.GetNewClosure() + } + $baselineSource = Resolve-PSModuleReleaseTagCommit @tagParameters + if ($baselineSource -ceq $Context.CommitSha) { + throw 'The original source already has publication records but recovery proof was not discovered. Do not invent a fallback version.' + } + } +} + function Get-ResolvedModuleVersion { <# .SYNOPSIS Resolves the next module version and resumes an incomplete stable release when possible. .DESCRIPTION - Normally the highest version from GitHub Releases and the PowerShell Gallery is the - version baseline. If Gallery contains exactly the stable version implied by the latest - GitHub release and this run's version bump, Gallery publication succeeded but GitHub - release creation did not. In that case, return the Gallery version rather than bumping - again so the workflow can resume the missing GitHub release. + Keeps the existing version decision for new releases and previews. A stable + retry first verifies actual source-bound tag and raw package evidence before + choosing the original version. Numeric equality and latest hints are not ownership. .OUTPUTS PSSemVer representing the resolved module version. @@ -771,10 +928,42 @@ function Get-ResolvedModuleVersion { [Parameter()] [AllowNull()] [AllowEmptyCollection()] - [array] $Releases = @() + [array] $Releases = @(), + + # Original consumer repository, producing PR/direct context, and immutable source. + [Parameter()] + [AllowNull()] + [PSCustomObject] $PublicationContext, + + # Injectable read-only proof boundaries; defaults to the consumer repository API. + [Parameter()] + [AllowNull()] + [hashtable] $PublicationReader ) LogGroup 'Resolve module version' { + if ($Decision.ShouldPublish -and $Decision.CreateRelease) { + if ($null -eq $PublicationContext) { + if ($PSGalleryVersion -gt $GitHubVersion) { + throw 'Source/artifact ownership proof is required before resolving a Gallery-only retry.' + } + } else { + if ($null -eq $PublicationReader) { + $PublicationReader = Get-PSModulePublicationReader -Repository $PublicationContext.Repository -ReadOnly + } + $recoveryParameters = @{ + Context = $PublicationContext + Configuration = $Configuration + ModuleName = $ModuleName + PSGalleryVersion = $PSGalleryVersion + Reader = $PublicationReader + } + $recovered = Get-PSModuleGalleryRecoveryVersion @recoveryParameters + if ($null -ne $recovered) { + return $recovered + } + } + } $latestVersion = Get-LatestPublishedVersion -GitHubVersion $GitHubVersion -PSGalleryVersion $PSGalleryVersion $params = @{ LatestVersion = $latestVersion @@ -785,23 +974,6 @@ function Get-ResolvedModuleVersion { } $resolvedVersion = Get-NextModuleVersion @params - if ($Decision.CreateRelease) { - $githubParams = $params.Clone() - $githubParams.LatestVersion = $GitHubVersion - $githubCandidate = Get-NextModuleVersion @githubParams - $galleryVersionString = "$($PSGalleryVersion.Major).$($PSGalleryVersion.Minor).$($PSGalleryVersion.Patch)" - $githubCandidateString = "$($githubCandidate.Major).$($githubCandidate.Minor).$($githubCandidate.Patch)" - - if ([string]::IsNullOrWhiteSpace($PSGalleryVersion.Prerelease) -and - $galleryVersionString -eq $githubCandidateString) { - Write-Host ( - "PowerShell Gallery contains [$galleryVersionString], the next stable version after " + - "GitHub [$GitHubVersion]. Resuming the Gallery-only publication." - ) - $resolvedVersion = $githubCandidate - } - } - $resolvedVersion } } diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index ad3b4a71..6190d239 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -9,6 +9,16 @@ Import-Module -Name "$PSScriptRoot/Resolve-PSModuleVersion.Helpers.psm1" -Force $actionInput = Read-ActionInput $config = Get-PublishConfiguration -SettingsJson $actionInput.SettingsJson $pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson +$settings = $actionInput.SettingsJson | ConvertFrom-Json -ErrorAction Stop +$publicationContext = [pscustomobject]@{ + Repository = $env:GITHUB_REPOSITORY + PullRequest = if ($null -eq $pullRequest.Number) { 0 } else { $pullRequest.Number } + CommitSha = if ([string]::IsNullOrWhiteSpace($settings.Context.CommitSha)) { + $env:GITHUB_SHA + } else { + [string]$settings.Context.CommitSha + } +} $decision = if ($null -eq $pullRequest) { # Non-PR event (for example workflow_dispatch or schedule): there are no pull request @@ -33,12 +43,13 @@ $ghVersion = Get-LatestGitHubVersion -Releases $releases $psGalleryVersion = Get-LatestPSGalleryVersion -ModuleName $actionInput.Name $params = @{ - GitHubVersion = $ghVersion - PSGalleryVersion = $psGalleryVersion - Decision = $decision - Configuration = $config - ModuleName = $actionInput.Name - Releases = $releases + GitHubVersion = $ghVersion + PSGalleryVersion = $psGalleryVersion + Decision = $decision + Configuration = $config + ModuleName = $actionInput.Name + Releases = $releases + PublicationContext = $publicationContext } $newVersion = Get-ResolvedModuleVersion @params diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 index 4a16a2ea..ab96286c 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -140,7 +140,7 @@ Describe 'Resolve-PSModuleVersion' { Describe 'Get-ResolvedModuleVersion' { Context 'Get-ResolvedModuleVersion - Gallery-only stable publication' { - It 'Get-ResolvedModuleVersion - reuses the Gallery version that is the next GitHub patch release' { + It 'Get-ResolvedModuleVersion - rejects numeric equality without source/artifact proof' { $params = @{ GitHubVersion = New-PSSemVer -Version '1.2.3' PSGalleryVersion = New-PSSemVer -Version '1.2.4' @@ -150,12 +150,10 @@ Describe 'Resolve-PSModuleVersion' { Releases = @() } - $result = Get-ResolvedModuleVersion @params - - $result.ToString() | Should -Be 'v1.2.4' + { Get-ResolvedModuleVersion @params } | Should -Throw '*proof*' } - It 'Get-ResolvedModuleVersion - preserves the normal Gallery baseline when versions do not identify a retry' { + It 'Get-ResolvedModuleVersion - rejects an unproven Gallery-only gap without inventing a version' { $params = @{ GitHubVersion = New-PSSemVer -Version '1.2.3' PSGalleryVersion = New-PSSemVer -Version '1.2.5' @@ -165,9 +163,7 @@ Describe 'Resolve-PSModuleVersion' { Releases = @() } - $result = Get-ResolvedModuleVersion @params - - $result.ToString() | Should -Be 'v1.2.6' + { Get-ResolvedModuleVersion @params } | Should -Throw '*proof*' } } } diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 new file mode 100644 index 00000000..24b109ff --- /dev/null +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 @@ -0,0 +1,210 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester fixture state is used by test blocks and injected readers.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeAll { + Import-Module PSModule -Force + Import-Module -Name ([IO.Path]::Combine($PSScriptRoot, '..', 'src', 'Resolve-PSModuleVersion.Helpers.psm1')) -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1' + )) -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Publish-PSModule', 'tests', 'Publish-PSModule.TestFixture.psm1' + )) -Force +} + +Describe 'Gallery-only version selection requires original ownership proof' { + BeforeEach { + $modulePath = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) -AdditionalChildPath 'TestModule' + $null = New-Item -Path $modulePath -ItemType Directory -Force + [IO.File]::WriteAllText((Join-Path -Path $modulePath -ChildPath 'TestModule.psm1'), '# Original tested payload.') + New-ModuleManifest -Path (Join-Path -Path $modulePath -ChildPath 'TestModule.psd1') ` + -RootModule TestModule.psm1 -ModuleVersion 1.2.4 -Author PublicationFixture ` + -Guid '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' -Description 'Anonymous recovery fixture.' + $zipPath = Join-Path -Path (Split-Path -Path $modulePath -Parent) -ChildPath 'original.zip' + $packagePath = Join-Path -Path (Split-Path -Path $modulePath -Parent) -ChildPath 'original.nupkg' + $null = New-PSModuleReleaseArchive -Path $modulePath -DestinationPath $zipPath + New-PSModuleFixturePackage -Path $modulePath -DestinationPath $packagePath -Version '1.2.4' + $claim = [pscustomobject]@{ + Repository = 'PSModule/TestModule' + PullRequest = 42 + CommitSha = '1111111111111111111111111111111111111111' + ModuleName = 'TestModule' + GalleryVersion = '1.2.4' + ReleaseTag = 'v1.2.4' + ReleaseType = 'Release' + ArtifactSha256 = Get-PSModuleArtifactDigest -Path $modulePath + AssetName = 'TestModule-1.2.4.zip' + AssetSha256 = (Get-FileHash -LiteralPath $zipPath).Hash.ToLowerInvariant() + } + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($claim | ConvertTo-Json -Compress))) + $record = [pscustomobject]@{ + Reference = [pscustomobject]@{ + ref = 'refs/tags/v1.2.4' + object = [pscustomobject]@{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } + } + Annotation = [pscustomobject]@{ + sha = '3333333333333333333333333333333333333333' + tag = 'v1.2.4' + object = [pscustomobject]@{ type = 'commit'; sha = $claim.CommitSha } + message = "" + } + Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + PackagePath = $packagePath + Release = $null + } + $state = @{ + Record = $record + Claims = @($record.Reference) + Calls = [Collections.Generic.List[string]]::new() + ModulePath = $modulePath + Claim = $claim + } + $script:state = $state + $reader = @{ + GetSource = { + param($Sha) + $state.Calls.Add('source-read') + $Sha + }.GetNewClosure() + GetTagList = { + $state.Calls.Add('tag-list-read') + $state.Claims + }.GetNewClosure() + GetTagReference = { + param($Tag) + $state.Calls.Add("tag-read:$Tag") + if ($Tag -ceq $state.Record.Annotation.tag) { $state.Record.Reference.object } + }.GetNewClosure() + GetTagObject = { + param($Sha) + if ($Sha -cne $state.Record.Annotation.sha) { throw 'Unexpected annotated tag lookup.' } + $state.Record.Annotation + }.GetNewClosure() + GetRelease = { + param($Tag) + $state.Calls.Add("release-read:$Tag") + if ($Tag -ceq $state.Record.Annotation.tag) { $state.Record.Release } + }.GetNewClosure() + GetPackage = { + param($Name, $Version) + $state.Calls.Add("package-read:$Version") + if ($Name -cne 'TestModule') { throw 'Unexpected module lookup.' } + if ($Version -ceq '1.2.4') { $state.Record.Package } + }.GetNewClosure() + GetPackageArtifactDigest = { + param($Name, $Version) + $state.Calls.Add("raw-package-read:$Version") + Get-PSModulePackageArtifactDigest -Path $state.Record.PackagePath -ModuleName $Name -GalleryVersion $Version + }.GetNewClosure() + } + $script:parameters = @{ + GitHubVersion = New-PSSemVer -Version '1.2.3' + PSGalleryVersion = New-PSSemVer -Version '1.2.4' + Decision = [pscustomobject]@{ + ShouldPublish = $true; CreateRelease = $true; CreatePrerelease = $false + MajorRelease = $false; MinorRelease = $false; PatchRelease = $true + HasVersionBump = $true; PrereleaseName = '' + } + Configuration = [pscustomobject]@{ + VersionPrefix = 'v'; AutoPatching = $true; IncrementalPrerelease = $false; DatePrereleaseFormat = '' + } + ModuleName = 'TestModule' + Releases = @() + PublicationContext = [pscustomobject]@{ + Repository = 'PSModule/TestModule'; PullRequest = 42; CommitSha = $claim.CommitSha + } + PublicationReader = $reader + } + } + + It 'never reuses numeric equality without immutable source and artifact proof' { + $parameters = $script:parameters.Clone() + $parameters.Remove('PublicationContext') + $parameters.Remove('PublicationReader') + + { Get-ResolvedModuleVersion @parameters } | Should -Throw '*proof*' + } + + It 'reuses the exact original version only after reading its raw package proof before Build' { + $result = Get-ResolvedModuleVersion @script:parameters + + $result.ToString() | Should -BeExactly 'v1.2.4' + $script:state.Calls | Should -Contain 'raw-package-read:1.2.4' + } + + It 'does not take a new number when latest Gallery and GitHub hints have advanced' { + $script:parameters.GitHubVersion = New-PSSemVer -Version '1.2.5' + $script:parameters.PSGalleryVersion = New-PSSemVer -Version '1.2.5' + + (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'v1.2.4' + } + + It 'rejects a Gallery-only version with unavailable legacy proof instead of inventing a fallback' { + $script:state.Record.Annotation.message = 'Legacy tag without artifact proof.' + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*proof*' + } + + It 'rejects the same commit when the claim belongs to another producing PR' { + $foreign = $script:state.Claim | ConvertTo-Json -Compress | ConvertFrom-Json + $foreign.PullRequest = 99 + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($foreign | ConvertTo-Json -Compress))) + $script:state.Record.Annotation.message = "" + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*context*' + } + + It 'rejects a foreign source with an incomplete Gallery-only publication instead of bumping' { + $script:state.Record.Annotation.object.sha = '2222222222222222222222222222222222222222' + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*source/artifact recovery context*' + } + + It 'keeps the normal bump for a different source after a completed legacy publication' { + $script:state.Record.Annotation.object.sha = '2222222222222222222222222222222222222222' + $script:state.Record.Annotation.message = 'Completed legacy tag.' + $script:state.Record.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; prerelease = $false; draft = $false + assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) + } + + (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'v1.2.5' + } + + It 'rejects changed package bytes despite matching source, version, and PR' { + [IO.File]::WriteAllText( + (Join-Path -Path $script:state.ModulePath -ChildPath 'TestModule.psm1'), '# Different package payload.' + ) + New-PSModuleFixturePackage -Path $script:state.ModulePath -DestinationPath $script:state.Record.PackagePath -Version '1.2.4' + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*artifact*' + } + + It 'rejects missing raw package evidence for an already-claimed source' { + $script:state.Record.Package = $null + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*package*' + } + + It 'propagates API failure rather than treating it as no recovery candidate' { + $script:parameters.PublicationReader.GetTagList = { throw 'Injected GitHub API HTTP 503.' } + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*503*' + } + + It 'leaves non-publishing preview decisions on the existing resolver path' { + $script:parameters.Decision.ShouldPublish = $false + $script:parameters.Decision.CreateRelease = $false + + $result = Get-ResolvedModuleVersion @script:parameters + + $result.ToString() | Should -BeExactly 'v1.2.5' + $script:state.Calls.Count | Should -Be 0 + } +} From db90fa3b2a032842c53dd7c4b60e5a6f79a8e261 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 17:09:56 +0100 Subject: [PATCH 09/27] Reproduce Gallery success GitHub failure and exact-byte retry end to end --- .../tests/Publish-PSModule.Recovery.Tests.ps1 | 108 ++++++++++++++++++ .../tests/Publish-PSModule.TestFixture.psm1 | 28 ++++- 2 files changed, 134 insertions(+), 2 deletions(-) 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..98365f69 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -12,6 +12,9 @@ BeforeAll { } Import-Module -Name 'PSModule' -Force Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Publish-PSModule.TestFixture.psm1') -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Resolve-PSModuleVersion', 'src', 'Resolve-PSModuleVersion.Helpers.psm1' + )) -Force $script:publishScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/publish.ps1' $script:environmentVariableNames = @( @@ -19,6 +22,7 @@ BeforeAll { 'GITHUB_REPOSITORY' 'GITHUB_SHA' 'GITHUB_WORKSPACE' + 'GITHUB_OUTPUT' 'PSMODULE_PUBLISH_PSMODULE_INPUT_Name' 'PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath' 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' @@ -26,6 +30,15 @@ BeforeAll { 'PSMODULE_PUBLISH_PSMODULE_INPUT_ReleaseTag' 'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha' 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' + 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath' + 'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest' + 'PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha' ) $script:originalEnvironment = @{} foreach ($name in $script:environmentVariableNames) { @@ -85,6 +98,36 @@ Describe 'Publish-PSModule recovery' { $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' + $script:releaseScriptPath = [IO.Path]::Combine( + $PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'release.ps1' + ) + $env:GITHUB_OUTPUT = Join-Path -Path $TestDrive -ChildPath 'output' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $script:moduleName + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = '.PSModule/module' + $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:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA + $script:recoveryParameters = @{ + GitHubVersion = New-PSSemVer -Version '1.2.3' + PSGalleryVersion = New-PSSemVer -Version '1.2.4' + ModuleName = 'TestModule' + Releases = @() + PublicationContext = [pscustomobject]@{ + Repository = 'PSModule/TestModule'; PullRequest = 0; CommitSha = $env:GITHUB_SHA + } + Configuration = [pscustomobject]@{ + VersionPrefix = 'v'; AutoPatching = $true; IncrementalPrerelease = $false; DatePrereleaseFormat = '' + } + Decision = [pscustomobject]@{ + ShouldPublish = $true; CreateRelease = $true; CreatePrerelease = $false + MajorRelease = $false; MinorRelease = $false; PatchRelease = $true + HasVersionBump = $true; PrereleaseName = '' + } + } } It 'rejects a Gallery-only version whose source and artifact proof is missing' { @@ -136,4 +179,69 @@ Describe 'Publish-PSModule recovery' { $script:state.GalleryWrites | Should -Be 0 $script:state.GitHubWrites | Should -Be 0 } + + It 'recovers Gallery success then GitHub failure using the same version and exact bytes' { + @{ head_commit = @{ message = 'Anonymous stable release notes.' } } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + $originalPackageHash = (Get-FileHash -LiteralPath $script:state.RawPackage).Hash + $script:state.FailReleaseCreate = $true + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*Failed to create*' + + $version = Get-ResolvedModuleVersion @script:recoveryParameters + $version.ToString() | Should -BeExactly 'v1.2.4' + $script:state.Calls.Add('build-start') + $script:state.Calls.IndexOf('raw-package-read') | Should -BeLessThan $script:state.Calls.IndexOf('build-start') + $script:state.FailReleaseCreate = $false + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.Release.tag_name | Should -BeExactly 'v1.2.4' + $script:state.Release.assets[0].digest | Should -BeExactly "sha256:$($script:state.ExpectedClaim.AssetSha256)" + (Get-FileHash -LiteralPath $script:state.RawPackage).Hash | Should -BeExactly $originalPackageHash + ($script:state.Calls -join "`n") | Should -Not -Match '--clobber' + } + + It 'rejects changed rebuilt bytes without a duplicate Gallery or GitHub upload' { + @{ head_commit = @{ message = 'Anonymous stable release notes.' } } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + $script:state.FailReleaseCreate = $true + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*Failed to create*' + (Get-ResolvedModuleVersion @script:recoveryParameters).ToString() | Should -BeExactly 'v1.2.4' + $writes = $script:state.GitHubCreationUpload + [IO.File]::WriteAllText( + (Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'), '# Different rebuilt artifact.' + ) + $script:state.FailReleaseCreate = $false + + { & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*artifact*' + + $script:state.GalleryWrites | Should -Be 1 + $script:state.GitHubCreationUpload | Should -Be $writes + $script:state.Release | Should -BeNullOrEmpty + } + + It 'recovers the original version and missing upload even when GitHub already has the release' { + @{ head_commit = @{ message = 'Complete anonymous stable notes.' } } | + ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + $script:state.FailAssetUpload = $true + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*Failed to upload*' + $originalNotes = $script:state.Release.body + $script:recoveryParameters.GitHubVersion = New-PSSemVer -Version '1.2.4' + + (Get-ResolvedModuleVersion @script:recoveryParameters).ToString() | Should -BeExactly 'v1.2.4' + $script:state.FailAssetUpload = $false + { & $script:publishScriptPath 1>$null 6>$null } | Should -Not -Throw + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw + + $script:state.GalleryWrites | Should -Be 1 + $script:state.Release.body | Should -BeExactly $originalNotes + @($script:state.Release.assets).Count | Should -Be 1 + @($script:state.Calls | Where-Object { $_ -ceq 'release-create' }).Count | Should -Be 1 + $script:state.Release.assets[0].digest | Should -BeExactly "sha256:$($script:state.ExpectedClaim.AssetSha256)" + } } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 index 2c5bce0c..3334c3f8 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 @@ -195,11 +195,13 @@ function New-PSModulePublicationFixture { Release = $null Package = $null RawPackage = '' + ModulePath = $Path ApiError = '' TagReads = 0 OnTagRead = $null OnTagCreate = $null FailReleaseCreate = $false + FailAssetUpload = $false ExpectedClaim = [pscustomobject]@{ Repository = "PSModule/$name" PullRequest = $PullRequest @@ -222,7 +224,7 @@ function New-PSModulePublicationFixture { [switch] $Prerelease ) if ($Name -ine $state.ExpectedClaim.ModuleName -or - $Version -cne $state.ExpectedClaim.GalleryVersion -or $Repository -cne 'PSGallery' -or + ($Version -and $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]." } @@ -235,6 +237,16 @@ function New-PSModulePublicationFixture { Set-Item -Path function:global:Publish-PSResource -Value { $state.Calls.Add('gallery-write') $state.GalleryWrites++ + $state.RawPackage = Join-Path -Path (Split-Path -Path $state.ModulePath -Parent) -ChildPath 'published-fixture.nupkg' + New-PSModuleFixturePackage -Path $state.ModulePath -DestinationPath $state.RawPackage ` + -Version $state.ExpectedClaim.GalleryVersion + $state.Package = [pscustomobject]@{ + Name = $state.ExpectedClaim.ModuleName + Version = ($state.ExpectedClaim.GalleryVersion -split '-', 2)[0] + Prerelease = if ($state.ExpectedClaim.ReleaseType -ceq 'Prerelease') { + ($state.ExpectedClaim.GalleryVersion -split '-', 2)[1] + } else { '' } + } }.GetNewClosure() Set-Item -Path function:global:Save-PSResource -Value { [CmdletBinding()] @@ -320,6 +332,11 @@ function New-PSModulePublicationFixture { $value = @{ full_name = $claim.Repository; permissions = @{ push = $true } } } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/commits/$($claim.CommitSha)") { $value = @{ sha = $claim.CommitSha } + } elseif ($endpoint -ceq "repos/$($claim.Repository)/git/matching-refs/tags/") { + $value = @() + if ($null -ne $state.Tag) { + $value = @(@{ ref = "refs/tags/$($claim.ReleaseTag)"; object = $state.Tag }) + } } elseif ($endpoint -like '*/git/ref/tags/*') { $state.TagReads++ if ($null -ne $state.OnTagRead) { @@ -340,10 +357,13 @@ function New-PSModulePublicationFixture { $global:LASTEXITCODE = 1 $value = @{ message = 'Not Found' } } - $json = $value | ConvertTo-Json -Depth 20 -Compress + $json = ConvertTo-Json -InputObject $value -Depth 20 -Compress if ($Arguments -contains '--include') { return "HTTP/2.0 $status Fixture`n`n$json" } + if ($Arguments[0] -ceq 'release' -and $Arguments[1] -ceq 'list') { + return '[{"tagName":"v1.2.3","isLatest":true,"isDraft":false,"isPrerelease":false}]' + } return $json } if ($Arguments[0] -ceq 'release' -and $Arguments[1] -ceq 'create') { @@ -374,6 +394,10 @@ function New-PSModulePublicationFixture { $state.GitHubWrites++ $state.GitHubCreationUpload++ $state.Calls.Add('asset-upload') + if ($state.FailAssetUpload) { + $global:LASTEXITCODE = 1 + return 'Injected missing-asset upload failure.' + } 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.' } From afe5fbc504fba24f1fc6671b12f4b9ac53bd2370 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 17:09:56 +0100 Subject: [PATCH 10/27] Document proven original-version recovery and safe legacy failures --- .../actions/Resolve-PSModuleVersion/README.md | 33 +++++++++++++++++++ .../content/guides/versioning-and-releases.md | 11 +++++++ 2 files changed, 44 insertions(+) create mode 100644 .github/actions/Resolve-PSModuleVersion/README.md diff --git a/.github/actions/Resolve-PSModuleVersion/README.md b/.github/actions/Resolve-PSModuleVersion/README.md new file mode 100644 index 00000000..972b5242 --- /dev/null +++ b/.github/actions/Resolve-PSModuleVersion/README.md @@ -0,0 +1,33 @@ +# Resolve-PSModuleVersion + +Resolves the module version once in Plan, before Build. Existing configured bump labels, preview behavior, version prefixes, and prerelease settings remain the decision authority. + +For a stable retry, the resolver searches actual version-tag claims for the original immutable consumer source and producing repository/PR or direct-release context. It uses the shared [publication preflight](../Publish-PSModule/README.md) to verify the exact raw Gallery package and any GitHub release/asset evidence before choosing the original version. Numeric equality, release names, mutable targets, and latest-version hints are not ownership proof. + +## Inputs + +| Name | Meaning | +| --- | --- | +| `Name` | Existing module-name input; defaults to the consumer repository name. | +| `Settings` | Complete settings JSON, including the existing normalized `Context.CommitSha`, producing `Context.PullRequest`, and publish configuration. | +| `WorkingDirectory` | Existing consumer working directory. | + +The normal workflow supplies these values internally. Recovery does not add a scalar bump input or replace the configured release-decision policy. + +## Outputs + +The existing `Version`, `Prerelease`, `FullVersion`, `ReleaseType`, and `CreateRelease` outputs remain unchanged. Build receives the proven original coordinates for recovery instead of a newly guessed version. + +## Recovery safety + +Only one exact source/context claim with matching raw package bytes can select a recovery version. An already-created GitHub release with a missing upload can resume through [Release-PSModule](../Release-PSModule/README.md). The publisher retains the proven Gallery package and verifies the rebuilt/tested artifact again before any write. + +| Failure | Safe behavior | +| --- | --- | +| Gallery-only package has missing, foreign, or legacy proof | Stop version resolution; do not reuse the package or invent a fallback number. | +| Same source has multiple claimed published versions | Stop ambiguous recovery for reviewed reconciliation. | +| Claim exists but its Gallery package is absent | Keep the original resolved version/tested artifact and rerun the original failed job. | +| Raw package or rebuilt artifact bytes differ | Stop; never replace published contents. | +| Authentication or API lookup fails | Surface the error instead of treating it as absence. | + +Legacy incomplete publication requires reviewed forward-only recovery. Do not fabricate provenance, retarget existing tags, change consumer metadata, or choose a version from a moving/latest hint. diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 8d36c6d5..a3e33b9c 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -94,6 +94,17 @@ publications without proof require reviewed forward-only recovery, not a guessed 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. +Stable recovery resolves the original version in Plan, before Build, by finding the actual tag claim for that +immutable source and producing context. Its raw Gallery package must match the recorded tested file tree. +Latest-version hints and numeric equality do not establish ownership. A changed source, foreign PR/context, +ambiguous claim, unavailable legacy proof, or API failure stops resolution without inventing a fallback version. + +If Gallery publication succeeded and GitHub creation or upload failed, the retry retains that proven version, +skips only the identical Gallery upload, and completes the missing GitHub work without replacing bytes. +Build and tests still run, and the publisher rechecks the resulting artifact against the original claim. +See the [resolver contract](https://github.com/PSModule/Process-PSModule/tree/main/.github/actions/Resolve-PSModuleVersion) +for safe recovery failures and the original-job retry path. + ## Linear versioning Only a single linear ancestry of versions is maintained. Old versions are not patched: if a security issue is found on From 438c7d040fbf82c0aaaa808dacf1f837913a1b7a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 17:06:19 +0100 Subject: [PATCH 11/27] 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 595891c9..bcbac8c0 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -451,6 +451,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 @@ -535,10 +538,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 3334c3f8..18eb864e 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1 @@ -200,6 +200,7 @@ function New-PSModulePublicationFixture { TagReads = 0 OnTagRead = $null OnTagCreate = $null + OnGeneratedNotes = $null FailReleaseCreate = $false FailAssetUpload = $false ExpectedClaim = [pscustomobject]@{ @@ -324,6 +325,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 98365f69..e4aad372 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -28,6 +28,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' 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' @@ -95,6 +96,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 18eb864e..171d1745 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 @@ -200,6 +208,7 @@ function New-PSModulePublicationFixture { TagReads = 0 OnTagRead = $null OnTagCreate = $null + OnVersionTagCreate = $null OnGeneratedNotes = $null FailReleaseCreate = $false FailAssetUpload = $false @@ -209,6 +218,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 @@ -310,18 +320,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") { @@ -342,15 +367,26 @@ function New-PSModulePublicationFixture { $value = @(@{ ref = "refs/tags/$($claim.ReleaseTag)"; object = $state.Tag }) } } 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 5fb356bb267539f2f77c42c2cbbde7259c2bb2f8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 18:45:53 +0100 Subject: [PATCH 15/27] Document shared package ownership and tested version binding (cherry picked from commit dc78ee268e63ef8a014020bd2f99f0d2739609c3) --- .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 a3e33b9c..20eaa1a1 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 f68b14ad56cc9816d6a693ac6eeaeaaad697777c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 18:59:25 +0100 Subject: [PATCH 16/27] Validate original resolver context before projection and bind shared version claims (cherry picked from commit 32b93d1f523d53ef635359c14d8203de58127854) --- .../tests/Publish-PSModule.Recovery.Tests.ps1 | 2 + .../src/Resolve-PSModuleVersion.Helpers.psm1 | 33 ++++++--- .../Resolve-PSModuleVersion.Context.Tests.ps1 | 70 +++++++++++++++++++ ...Resolve-PSModuleVersion.Recovery.Tests.ps1 | 9 +++ 4 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 .github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Context.Tests.ps1 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 e4aad372..9e05dfc0 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -38,6 +38,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' ) @@ -111,6 +112,7 @@ Describe 'Publish-PSModule recovery' { $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'false' $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.4' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix = 'v' $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA $script:recoveryParameters = @{ diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index 2daa205d..a63d86ab 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -159,14 +159,18 @@ function Get-GitHubPullRequest { ) LogGroup 'Event information' { - $settings = $SettingsJson | ConvertFrom-Json + $settings = ConvertFrom-Json -InputObject $SettingsJson -NoEnumerate -ErrorAction Stop + if ($settings -is [array] -or $settings -isnot [PSCustomObject]) { + throw 'Settings must contain one normalized release-context object.' + } $context = $settings.Context if ($context) { $contextPullRequest = $context.PullRequest - if ($contextPullRequest) { - Write-Host "Using normalized pull request context for #$($contextPullRequest.Number)." + if ($null -ne $contextPullRequest) { + $number = Get-PSModuleProducingPullRequestNumber -PullRequest $contextPullRequest + Write-Host "Using normalized pull request context for #$number." return [PSCustomObject]@{ - Number = $contextPullRequest.Number + Number = $number HeadRef = $contextPullRequest.HeadRef Labels = @($contextPullRequest.Labels) } @@ -184,17 +188,22 @@ function Get-GitHubPullRequest { } $eventJsonInput = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson - $githubEvent = if (-not [string]::IsNullOrWhiteSpace($eventJsonInput)) { - $eventJsonInput | ConvertFrom-Json + if (-not [string]::IsNullOrWhiteSpace($eventJsonInput)) { + $githubEvent = ConvertFrom-Json -InputObject $eventJsonInput -NoEnumerate -ErrorAction Stop } else { - Get-Content $env:GITHUB_EVENT_PATH | ConvertFrom-Json + $githubEvent = Get-Content -Raw -LiteralPath $env:GITHUB_EVENT_PATH | + ConvertFrom-Json -NoEnumerate -ErrorAction Stop + } + if ($githubEvent -is [array] -or $githubEvent -isnot [PSCustomObject]) { + throw 'The event must contain one producing-context object.' } $pr = $githubEvent.pull_request - if (-not $pr) { + if ($null -eq $pr) { Write-Host 'GitHub event does not contain pull_request data and no release context was normalized.' return $null } + $number = Get-PSModuleProducingPullRequestNumber -PullRequest $pr $labels = @() $labels += $pr.labels.name @@ -207,6 +216,7 @@ function Get-GitHubPullRequest { Write-Host '-------------------------------------------------' [PSCustomObject]@{ + Number = $number HeadRef = $pr.head.ref Labels = $labels } @@ -801,7 +811,11 @@ function Get-PSModuleGalleryRecoveryVersion { -not [long]::TryParse([string]$Context.PullRequest, [ref]$number) -or $number -lt 0) { throw 'Immutable repository/PR/source context is required for Gallery recovery proof.' } - foreach ($name in @('GetSource', 'GetTagList', 'GetTagReference', 'GetTagObject', 'GetRelease', 'GetPackage', 'GetPackageArtifactDigest')) { + $readerNames = @( + 'GetSource', 'GetTagList', 'GetTagReference', 'GetVersionReference', + 'GetTagObject', 'GetRelease', 'GetPackage', 'GetPackageArtifactDigest' + ) + foreach ($name in $readerNames) { if ($Reader[$name] -isnot [scriptblock]) { throw "The immutable publication reader [$name] is required for Gallery recovery proof." } @@ -848,6 +862,7 @@ function Get-PSModuleGalleryRecoveryVersion { } if ($claim.Repository -ine $Context.Repository -or $claim.PullRequest -ne $number -or $claim.CommitSha -cne $Context.CommitSha -or $claim.ModuleName -cne $ModuleName -or + $claim.VersionPrefix -cne $prefix -or $claim.ReleaseType -cne 'Release' -or $claim.ReleaseTag -cne $tag -or "$prefix$($claim.GalleryVersion)" -cne $tag) { throw "The source-bound tag [$tag] belongs to a different module or release context." diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Context.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Context.Tests.ps1 new file mode 100644 index 00000000..ac696ccc --- /dev/null +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Context.Tests.ps1 @@ -0,0 +1,70 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester setup state is used by test blocks.' +)] +[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 action-test environment lock.' + } + Import-Module PSModule -Force + Import-Module -Name ([IO.Path]::Combine( + $PSScriptRoot, '..', 'src', 'Resolve-PSModuleVersion.Helpers.psm1' + )) -Force + $script:eventInput = [Environment]::GetEnvironmentVariable('PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson') +} + +AfterAll { + try { + [Environment]::SetEnvironmentVariable('PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson', $script:eventInput) + } finally { + $script:environmentMutex.ReleaseMutex() + $script:environmentMutex.Dispose() + } +} + +Describe 'Producing context is validated before resolver projection' { + It 'retains the actual PR number in the supported event fallback' { + $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = @' +{"pull_request":{"number":42,"head":{"ref":"fixture"},"labels":[{"name":"patch"}]}} +'@ + + $result = Get-GitHubPullRequest -SettingsJson '{}' + + $result.Number | Should -Be 42 + $result.HeadRef | Should -BeExactly 'fixture' + $result.Labels | Should -Be @('patch') + } + + It 'rejects raw normalized malformed PR context before creating a direct identity' -ForEach @( + @{ Raw = '{}' } + @{ Raw = '[]' } + @{ Raw = '[null]' } + @{ Raw = '[{"Number":42}]' } + @{ Raw = '{"Number":0}' } + ) { + $settings = '{"Context":{"IsPushToDefaultBranch":true,"DefaultBranch":"main","PullRequest":' + $Raw + '}}' + + { Get-GitHubPullRequest -SettingsJson $settings } | Should -Throw '*pull request*' + } + + It 'rejects an event PR array before projecting its Number' { + $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = '{"pull_request":[{"number":42}]}' + + { Get-GitHubPullRequest -SettingsJson '{}' } | Should -Throw '*pull request*' + } + + It 'retains genuinely absent context for supported direct releases' { + $settings = '{"Context":{"IsPushToDefaultBranch":true,"DefaultBranch":"main","PullRequest":null}}' + + $result = Get-GitHubPullRequest -SettingsJson $settings + + $result.IsDirectRelease | Should -BeTrue + $result.Number | Should -BeNullOrEmpty + } +} diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 index 24b109ff..cd6c0f92 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 @@ -36,6 +36,7 @@ Describe 'Gallery-only version selection requires original ownership proof' { CommitSha = '1111111111111111111111111111111111111111' ModuleName = 'TestModule' GalleryVersion = '1.2.4' + VersionPrefix = 'v' ReleaseTag = 'v1.2.4' ReleaseType = 'Release' ArtifactSha256 = Get-PSModuleArtifactDigest -Path $modulePath @@ -81,6 +82,14 @@ Describe 'Gallery-only version selection requires original ownership proof' { $state.Calls.Add("tag-read:$Tag") if ($Tag -ceq $state.Record.Annotation.tag) { $state.Record.Reference.object } }.GetNewClosure() + GetVersionReference = { + param($Name, $Version) + if ($Name -cne 'TestModule' -or $Version -cne '1.2.4') { + throw 'Unexpected module/version coordinate lookup.' + } + $state.Calls.Add('module-version-coordinate-read') + $state.Record.Reference.object + }.GetNewClosure() GetTagObject = { param($Sha) if ($Sha -cne $state.Record.Annotation.sha) { throw 'Unexpected annotated tag lookup.' } From 61f81ff94681879aa99b9b62b54f6e1e1ce23381 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:18:29 +0100 Subject: [PATCH 17/27] 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. (cherry picked from commit 6254d3442778d07a776a5c4be4adba4ca37ba571) --- .../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 ff6bf3fc29eccd79f9e8003f552bccb5fcc51057 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:21:41 +0100 Subject: [PATCH 18/27] 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. (cherry picked from commit 474d0946a6bcf4112b097a84557c7c76465e84a6) --- .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 2d751b3b..8e2b8357 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -626,12 +626,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 6abfb431f6123b74717d696990ac9dd808e81317 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 19:54:48 +0100 Subject: [PATCH 19/27] 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. (cherry picked from commit 2afb9294a6de5452ba0d9aba00c722529b0eece5) --- .../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 85003e1f2210da2bd02b81d20da9ae33837d6476 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 20:21:28 +0100 Subject: [PATCH 20/27] Reproduce original event context projection before publisher validation (cherry picked from commit 131c30b1ec476717b140a5703673ada72a854bfb) --- .../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 c2542d4ac0ef0b77efa2a2b2d6bc24fc22999a31 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:37:35 +0100 Subject: [PATCH 21/27] 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. (cherry picked from commit e59ac33a8627e021ec89d380988d14cc5eae44fc) --- .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 2c4ecf6d41b23fb00ebb21b64645de0c3540ab73 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:46:20 +0100 Subject: [PATCH 22/27] Reproduce shared-only claims and historical-prefix recovery baselines Four isolated cases show unpublished claims bypassed, missing-tag proof misclassified, changed-prefix completed baselines rejected, and ambiguous baseline refs selected. Twelve existing controls remain green. (cherry picked from commit 136af136bb42e108754fe5cee21d45118ad52c18) --- ...Resolve-PSModuleVersion.Recovery.Tests.ps1 | 133 ++++++++++++++++-- 1 file changed, 123 insertions(+), 10 deletions(-) diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 index cd6c0f92..8065d1de 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 @@ -49,6 +49,10 @@ Describe 'Gallery-only version selection requires original ownership proof' { ref = 'refs/tags/v1.2.4' object = [pscustomobject]@{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } } + VersionReference = [pscustomobject]@{ + ref = 'refs/tags/PSModule-Publication/testmodule/1.2.4' + object = [pscustomobject]@{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } + } Annotation = [pscustomobject]@{ sha = '3333333333333333333333333333333333333333' tag = 'v1.2.4' @@ -61,11 +65,13 @@ Describe 'Gallery-only version selection requires original ownership proof' { } $state = @{ Record = $record + Records = [Collections.Generic.List[object]]::new() Claims = @($record.Reference) Calls = [Collections.Generic.List[string]]::new() ModulePath = $modulePath Claim = $claim } + $state.Records.Add($record) $script:state = $state $reader = @{ GetSource = { @@ -80,36 +86,53 @@ Describe 'Gallery-only version selection requires original ownership proof' { GetTagReference = { param($Tag) $state.Calls.Add("tag-read:$Tag") - if ($Tag -ceq $state.Record.Annotation.tag) { $state.Record.Reference.object } + foreach ($item in $state.Records) { + if ($Tag -ceq $item.Annotation.tag) { return $item.Reference.object } + } }.GetNewClosure() GetVersionReference = { param($Name, $Version) - if ($Name -cne 'TestModule' -or $Version -cne '1.2.4') { - throw 'Unexpected module/version coordinate lookup.' - } + if ($Name -cne 'TestModule') { throw 'Unexpected module/version coordinate lookup.' } $state.Calls.Add('module-version-coordinate-read') - $state.Record.Reference.object + foreach ($item in $state.Records) { + if ($item.VersionReference.ref -ceq "refs/tags/PSModule-Publication/testmodule/$Version") { + return $item.VersionReference.object + } + } }.GetNewClosure() GetTagObject = { param($Sha) - if ($Sha -cne $state.Record.Annotation.sha) { throw 'Unexpected annotated tag lookup.' } - $state.Record.Annotation + foreach ($item in $state.Records) { + if ($Sha -ceq $item.Annotation.sha) { return $item.Annotation } + } + throw 'Unexpected annotated tag lookup.' }.GetNewClosure() GetRelease = { param($Tag) $state.Calls.Add("release-read:$Tag") - if ($Tag -ceq $state.Record.Annotation.tag) { $state.Record.Release } + foreach ($item in $state.Records) { + if ($Tag -ceq $item.Annotation.tag) { return $item.Release } + } }.GetNewClosure() GetPackage = { param($Name, $Version) $state.Calls.Add("package-read:$Version") if ($Name -cne 'TestModule') { throw 'Unexpected module lookup.' } - if ($Version -ceq '1.2.4') { $state.Record.Package } + foreach ($item in $state.Records) { + if ($item.VersionReference.ref -ceq "refs/tags/PSModule-Publication/testmodule/$Version") { + return $item.Package + } + } }.GetNewClosure() GetPackageArtifactDigest = { param($Name, $Version) $state.Calls.Add("raw-package-read:$Version") - Get-PSModulePackageArtifactDigest -Path $state.Record.PackagePath -ModuleName $Name -GalleryVersion $Version + foreach ($item in $state.Records) { + if ($item.VersionReference.ref -ceq "refs/tags/PSModule-Publication/testmodule/$Version") { + return Get-PSModulePackageArtifactDigest -Path $item.PackagePath -ModuleName $Name -GalleryVersion $Version + } + } + throw 'Unexpected raw package lookup.' }.GetNewClosure() } $script:parameters = @{ @@ -154,6 +177,54 @@ Describe 'Gallery-only version selection requires original ownership proof' { (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'v1.2.4' } + It 'deduplicates shared and release refs for one exact original publication' { + $script:state.Claims = @($script:state.Record.Reference, $script:state.Record.VersionReference) + + (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'v1.2.4' + } + + It 'rejects an unpublished shared-only claim before a later completed baseline can select a new version' { + $original = $script:state.Record + $original.Reference = $null + $original.Package = $null + $later = [pscustomobject]@{ + Reference = [pscustomobject]@{ + ref = 'refs/tags/v1.2.5' + object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } + } + VersionReference = [pscustomobject]@{ + ref = 'refs/tags/PSModule-Publication/testmodule/1.2.5' + object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } + } + Annotation = [pscustomobject]@{ + sha = '4444444444444444444444444444444444444444'; tag = 'v1.2.5'; message = 'Completed later legacy baseline.' + object = [pscustomobject]@{ type = 'commit'; sha = '2222222222222222222222222222222222222222' } + } + Release = [pscustomobject]@{ + tag_name = 'v1.2.5'; prerelease = $false; draft = $false + assets = @([pscustomobject]@{ name = 'TestModule-1.2.5.zip'; state = 'uploaded' }) + } + } + $script:state.Records.Add($later) + $script:state.Claims = @($original.VersionReference, $later.Reference) + $script:parameters.GitHubVersion = New-PSSemVer -Version '1.2.5' + $script:parameters.PSGalleryVersion = New-PSSemVer -Version '1.2.5' + Mock -CommandName Get-NextModuleVersion -ModuleName Resolve-PSModuleVersion.Helpers -MockWith { + New-PSSemVer -Version 'v1.2.6' + } + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*original source claim*no published raw package proof*' + + Should -Invoke -CommandName Get-NextModuleVersion -ModuleName Resolve-PSModuleVersion.Helpers -Times 0 -Exactly + } + + It 'rejects a shared-only published package whose observed release tag is missing' { + $script:state.Record.Reference = $null + $script:state.Claims = @($script:state.Record.VersionReference) + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*no verifiable source/artifact ownership proof*' + } + It 'rejects a Gallery-only version with unavailable legacy proof instead of inventing a fallback' { $script:state.Record.Annotation.message = 'Legacy tag without artifact proof.' @@ -186,6 +257,48 @@ Describe 'Gallery-only version selection requires original ownership proof' { (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'v1.2.5' } + It 'uses the actual completed historical tag when the new source changes its prefix' { + $script:state.Record.Annotation.object.sha = '2222222222222222222222222222222222222222' + $script:state.Record.Annotation.message = 'Completed legacy tag.' + $script:state.Record.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; prerelease = $false; draft = $false + assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) + } + $script:parameters.Configuration.VersionPrefix = 'release-' + + (Get-ResolvedModuleVersion @script:parameters).ToString() | Should -BeExactly 'release-1.2.5' + + $script:state.Calls | Should -Contain 'release-read:v1.2.4' + $script:state.Calls | Should -Not -Contain 'release-read:release-1.2.4' + } + + It 'rejects ambiguous completed historical baseline tags instead of choosing a prefix' { + $script:state.Record.Annotation.object.sha = '2222222222222222222222222222222222222222' + $script:state.Record.Annotation.message = 'Completed legacy tag.' + $script:state.Record.Release = [pscustomobject]@{ + tag_name = 'v1.2.4'; prerelease = $false; draft = $false + assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) + } + $other = [pscustomobject]@{ + Reference = [pscustomobject]@{ + ref = 'refs/tags/release-1.2.4' + object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } + } + Annotation = [pscustomobject]@{ + sha = '4444444444444444444444444444444444444444'; tag = 'release-1.2.4'; message = 'Another completed legacy baseline.' + object = [pscustomobject]@{ type = 'commit'; sha = '2222222222222222222222222222222222222222' } + } + Release = [pscustomobject]@{ + tag_name = 'release-1.2.4'; prerelease = $false; draft = $false + assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) + } + } + $script:state.Records.Add($other) + $script:state.Claims = @($script:state.Record.Reference, $other.Reference) + + { Get-ResolvedModuleVersion @script:parameters } | Should -Throw '*ambiguous*baseline*' + } + It 'rejects changed package bytes despite matching source, version, and PR' { [IO.File]::WriteAllText( (Join-Path -Path $script:state.ModulePath -ChildPath 'TestModule.psm1'), '# Different package payload.' From c665a9bb44711d36684098f3f3733748e11213f4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:54:42 +0100 Subject: [PATCH 23/27] Discover shared-only claims and verify actual historical release baselines Deduplicate immutable alias refs and stop unpublished source claims before selecting another version. Use actual historical tag and release evidence for new-source completeness without changing configured-prefix binding or bump policy. (cherry picked from commit 3bdda6582e0abb1767707136f2bb5e1cee3c3bdd) --- .../actions/Resolve-PSModuleVersion/README.md | 4 +- .../src/Resolve-PSModuleVersion.Helpers.psm1 | 151 +++++++++++++----- ...Resolve-PSModuleVersion.Recovery.Tests.ps1 | 26 +-- .../content/guides/versioning-and-releases.md | 9 +- 4 files changed, 137 insertions(+), 53 deletions(-) diff --git a/.github/actions/Resolve-PSModuleVersion/README.md b/.github/actions/Resolve-PSModuleVersion/README.md index 972b5242..6e70c116 100644 --- a/.github/actions/Resolve-PSModuleVersion/README.md +++ b/.github/actions/Resolve-PSModuleVersion/README.md @@ -2,7 +2,7 @@ Resolves the module version once in Plan, before Build. Existing configured bump labels, preview behavior, version prefixes, and prerelease settings remain the decision authority. -For a stable retry, the resolver searches actual version-tag claims for the original immutable consumer source and producing repository/PR or direct-release context. It uses the shared [publication preflight](../Publish-PSModule/README.md) to verify the exact raw Gallery package and any GitHub release/asset evidence before choosing the original version. Numeric equality, release names, mutable targets, and latest-version hints are not ownership proof. +For a stable retry, the resolver searches both release-tag and shared module/version claims for the original immutable consumer source and producing repository/PR or direct-release context. Refs pointing to the same immutable claim are deduplicated. It uses the shared [publication preflight](../Publish-PSModule/README.md) to verify the exact raw Gallery package and any GitHub release/asset evidence before choosing the original version. Numeric equality, release names, mutable targets, and latest-version hints are not ownership proof. ## Inputs @@ -22,6 +22,8 @@ The existing `Version`, `Prerelease`, `FullVersion`, `ReleaseType`, and `CreateR Only one exact source/context claim with matching raw package bytes can select a recovery version. An already-created GitHub release with a missing upload can resume through [Release-PSModule](../Release-PSModule/README.md). The publisher retains the proven Gallery package and verifies the rebuilt/tested artifact again before any write. +An unpublished shared-only claim still stops Plan for original-job retry, even after a later source completes another version. A new source checks baseline completeness using the actual historical tag and release, not a tag reconstructed with the current configured prefix. Recovery and new claims still require exact configured-prefix binding; ambiguous historical baselines stop explicitly. + | Failure | Safe behavior | | --- | --- | | Gallery-only package has missing, foreign, or legacy proof | Stop version resolution; do not reuse the package or invent a fallback number. | diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index a63d86ab..8276cd33 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -761,7 +761,7 @@ function Get-PSModuleGalleryRecoveryVersion { Recover a stable version only from exact source/context and raw package proof. .DESCRIPTION - Searches actual version-tag claims, not latest hints or numeric equality. + Searches actual release and shared-coordinate claims, not latest hints or numeric equality. The canonical preflight validates the original package/release evidence before Build. A claimed source without package proof, conflicting context, or multiple versions fails. @@ -806,7 +806,8 @@ function Get-PSModuleGalleryRecoveryVersion { ) $number = 0L - if ($Context.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or + if ($Context.Repository -isnot [string] -or $Context.CommitSha -isnot [string] -or + $Context.PullRequest -is [array] -or $Context.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or $Context.CommitSha -notmatch '^[0-9a-f]{40}$' -or -not [long]::TryParse([string]$Context.PullRequest, [ref]$number) -or $number -lt 0) { throw 'Immutable repository/PR/source context is required for Gallery recovery proof.' @@ -824,25 +825,61 @@ function Get-PSModuleGalleryRecoveryVersion { if ($source -cne $Context.CommitSha) { throw 'The original consumer source could not be verified before Build.' } - $owned = [Collections.Generic.List[object]]::new() + $owned = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + $baselineTags = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $baselineClaims = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) $references = @(& $Reader.GetTagList) $prefix = [string]$Configuration.VersionPrefix + $coordinatePrefix = 'PSModule-Publication/' + $stablePattern = '^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$' + $versionPattern = '^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$' + $galleryVersion = $null + if ($PSGalleryVersion.ToString() -ne '0.0.0') { + $galleryVersion = "$($PSGalleryVersion.Major).$($PSGalleryVersion.Minor).$($PSGalleryVersion.Patch)" + } foreach ($reference in $references) { if ($reference.ref -isnot [string] -or -not $reference.ref.StartsWith('refs/tags/') -or - $null -eq $reference.object) { + $null -eq $reference.object -or $reference.object -is [array] -or + $reference.object.sha -isnot [string] -or $reference.object.type -isnot [string] -or + $reference.object.sha -notmatch '^[0-9a-f]{40}$' -or + $reference.object.type -cnotin @('tag', 'commit')) { throw 'The publication tag enumeration returned unverifiable reference metadata.' } $tag = $reference.ref.Substring('refs/tags/'.Length) - if (-not $tag.StartsWith($prefix, [StringComparison]::Ordinal) -or - $tag.Substring($prefix.Length) -notmatch '^\d+\.\d+\.\d+$') { - continue + if ($null -ne $galleryVersion -and -not $tag.StartsWith('PSModule-Publication/', [StringComparison]::Ordinal) -and + $tag -cmatch "^\D*$([regex]::Escape($galleryVersion))$") { + $null = $baselineTags.Add($tag) + } + $isCoordinate = $tag.StartsWith($coordinatePrefix, [StringComparison]::Ordinal) + if ($isCoordinate) { + $coordinateParts = $tag.Substring($coordinatePrefix.Length).Split('/') + if ($coordinateParts.Count -ne 2) { + throw "The module/version coordinate [$tag] has unverifiable reference metadata." + } + $tagVersion = $coordinateParts[1] + if ($tagVersion -notmatch $versionPattern) { + throw "The module/version coordinate [$tag] has unverifiable version metadata." + } + $moduleCoordinate = Get-PSModulePublicationVersionTag -ModuleName $ModuleName -GalleryVersion $tagVersion + if ($tag -cne $moduleCoordinate) { continue } + if ($tagVersion.Contains('-')) { continue } + } else { + if (-not $tag.StartsWith($prefix, [StringComparison]::Ordinal) -or + $tag.Substring($prefix.Length) -notmatch $stablePattern) { + continue + } + $tagVersion = $tag.Substring($prefix.Length) } $evidence = @{ Annotation = $null; Reference = $null } $parameters = @{ Tag = $tag GetTagReference = { param($Tag) - $evidence.Reference = & $Reader.GetTagReference $Tag + if ($isCoordinate) { + $evidence.Reference = & $Reader.GetVersionReference $ModuleName $tagVersion + } else { + $evidence.Reference = & $Reader.GetTagReference $Tag + } $evidence.Reference }.GetNewClosure() GetTagObject = { @@ -853,61 +890,101 @@ function Get-PSModuleGalleryRecoveryVersion { }.GetNewClosure() } $tagCommit = Resolve-PSModuleReleaseTagCommit @parameters + $claim = $null + if ($isCoordinate -or $tagCommit -ceq $Context.CommitSha) { + $claim = Get-PSModulePublicationClaim -Message $evidence.Annotation.message + if ($null -eq $claim) { + throw "Existing source-bound claim [$tag] has no verifiable artifact/release-context proof. Do not invent a fallback version." + } + if ($claim.CommitSha -cne $tagCommit -or $claim.Repository -ine $Context.Repository -or + $claim.ModuleName -cne $ModuleName -or $claim.GalleryVersion -cne $tagVersion -or + $claim.ReleaseType -cne 'Release') { + throw "The immutable claim [$tag] has conflicting source/module/version proof." + } + if ($isCoordinate) { + $expectedCoordinate = Get-PSModulePublicationVersionTag -ModuleName $claim.ModuleName -GalleryVersion $claim.GalleryVersion + if ($tag -cne $expectedCoordinate) { + throw "The shared claim [$tag] does not bind its exact module/version coordinate." + } + } elseif ($claim.ReleaseTag -cne $tag) { + throw "The source-bound tag [$tag] has conflicting release coordinates." + } + if ($null -ne $galleryVersion -and $claim.GalleryVersion -ceq $galleryVersion) { + $null = $baselineTags.Add($claim.ReleaseTag) + $baselineClaims[$claim.ReleaseTag] = $claim + } + } if ($tagCommit -cne $Context.CommitSha) { continue } - $claim = Get-PSModulePublicationClaim -Message $evidence.Annotation.message - if ($null -eq $claim) { - throw "Existing source-bound tag [$tag] has no verifiable artifact/release-context proof. Do not invent a fallback version." - } if ($claim.Repository -ine $Context.Repository -or $claim.PullRequest -ne $number -or $claim.CommitSha -cne $Context.CommitSha -or $claim.ModuleName -cne $ModuleName -or $claim.VersionPrefix -cne $prefix -or - $claim.ReleaseType -cne 'Release' -or $claim.ReleaseTag -cne $tag -or - "$prefix$($claim.GalleryVersion)" -cne $tag) { + $claim.ReleaseType -cne 'Release' -or "$prefix$($claim.GalleryVersion)" -cne $claim.ReleaseTag) { throw "The source-bound tag [$tag] belongs to a different module or release context." } $state = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $Reader - if ($state.TagSha -cne $evidence.Reference.sha) { + $observedSha = $state.TagSha + if ($isCoordinate) { $observedSha = $state.VersionTagSha } + if ($observedSha -cne $evidence.Reference.sha -or $observedSha -cne $reference.object.sha) { throw "Publication claim [$tag] changed while verifying recovery proof. Refusing version resolution." } if ($null -eq $state.Package) { throw ("The original source claim [$tag] has no published raw package proof. " + 'Retain its resolved version/artifact and rerun the original job.') } - $owned.Add($claim) + $owned[$observedSha] = $claim } if ($owned.Count -gt 1) { throw 'Multiple published versions claim this exact source and release context. Refusing ambiguous recovery.' } if ($owned.Count -eq 1) { - $version = New-PSSemVer -Version $owned[0].GalleryVersion + $original = @($owned.Values)[0] + $version = New-PSSemVer -Version $original.GalleryVersion $version.Prefix = $prefix Write-Verbose "Recovering proven original publication [$($version.ToString())] before Build." return $version } - if ($PSGalleryVersion.ToString() -ne '0.0.0') { - $galleryVersion = "$($PSGalleryVersion.Major).$($PSGalleryVersion.Minor).$($PSGalleryVersion.Patch)" - $tag = "$prefix$galleryVersion" - $release = & $Reader.GetRelease $tag - $assets = @($release.assets | Where-Object { - $_.name -ceq "$ModuleName-$galleryVersion.zip" -and $_.state -ceq 'uploaded' - }) - if ($null -eq $release -or $release.tag_name -cne $tag -or $release.prerelease -ne $false -or - $release.draft -ne $false -or $assets.Count -ne 1) { - throw "Gallery-only package [$galleryVersion] has no proven source/artifact recovery context. Do not invent a fallback version." + if ($null -ne $galleryVersion) { + $completed = [Collections.Generic.List[string]]::new() + foreach ($tag in $baselineTags) { + $release = & $Reader.GetRelease $tag + $assets = @($release.assets | Where-Object { + $_.name -ceq "$ModuleName-$galleryVersion.zip" -and $_.state -ceq 'uploaded' + }) + if ($null -eq $release -or $release.tag_name -cne $tag -or $release.prerelease -isnot [bool] -or + $release.prerelease -ne $false -or $release.draft -isnot [bool] -or $release.draft -ne $false -or + $assets.Count -ne 1) { + continue + } + if ($baselineClaims.ContainsKey($tag)) { + $baselineClaim = $baselineClaims[$tag] + $baselineState = Get-PSModulePublicationState -ExpectedClaim $baselineClaim -Reader $Reader + if ($null -eq $baselineState.Package) { + throw 'The historical Gallery baseline has no exact published-package proof.' + } + $baselineSource = $baselineClaim.CommitSha + } else { + $tagParameters = @{ + Tag = $tag + GetTagReference = $Reader.GetTagReference + GetTagObject = { + param($Sha) + (& $Reader.GetTagObject $Sha).object + }.GetNewClosure() + } + $baselineSource = Resolve-PSModuleReleaseTagCommit @tagParameters + } + if ($baselineSource -ceq $Context.CommitSha) { + throw 'The original source already has publication records but recovery proof was not discovered. Do not invent a fallback version.' + } + $completed.Add($tag) } - $tagParameters = @{ - Tag = $tag - GetTagReference = $Reader.GetTagReference - GetTagObject = { - param($Sha) - (& $Reader.GetTagObject $Sha).object - }.GetNewClosure() + if ($completed.Count -gt 1) { + throw 'Multiple completed tags provide an ambiguous Gallery baseline. Refusing version resolution.' } - $baselineSource = Resolve-PSModuleReleaseTagCommit @tagParameters - if ($baselineSource -ceq $Context.CommitSha) { - throw 'The original source already has publication records but recovery proof was not discovered. Do not invent a fallback version.' + if ($completed.Count -eq 0) { + throw "Gallery-only package [$galleryVersion] has no proven source/artifact recovery context. Do not invent a fallback version." } } } diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 index 8065d1de..07fd5ebd 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Recovery.Tests.ps1 @@ -45,7 +45,7 @@ Describe 'Gallery-only version selection requires original ownership proof' { } $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($claim | ConvertTo-Json -Compress))) $record = [pscustomobject]@{ - Reference = [pscustomobject]@{ + Reference = [pscustomobject]@{ ref = 'refs/tags/v1.2.4' object = [pscustomobject]@{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } } @@ -53,15 +53,15 @@ Describe 'Gallery-only version selection requires original ownership proof' { ref = 'refs/tags/PSModule-Publication/testmodule/1.2.4' object = [pscustomobject]@{ type = 'tag'; sha = '3333333333333333333333333333333333333333' } } - Annotation = [pscustomobject]@{ + Annotation = [pscustomobject]@{ sha = '3333333333333333333333333333333333333333' tag = 'v1.2.4' object = [pscustomobject]@{ type = 'commit'; sha = $claim.CommitSha } message = "" } - Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } - PackagePath = $packagePath - Release = $null + Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' } + PackagePath = $packagePath + Release = $null } $state = @{ Record = $record @@ -188,19 +188,19 @@ Describe 'Gallery-only version selection requires original ownership proof' { $original.Reference = $null $original.Package = $null $later = [pscustomobject]@{ - Reference = [pscustomobject]@{ - ref = 'refs/tags/v1.2.5' + Reference = [pscustomobject]@{ + ref = 'refs/tags/v1.2.5' object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } } VersionReference = [pscustomobject]@{ - ref = 'refs/tags/PSModule-Publication/testmodule/1.2.5' + ref = 'refs/tags/PSModule-Publication/testmodule/1.2.5' object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } } - Annotation = [pscustomobject]@{ + Annotation = [pscustomobject]@{ sha = '4444444444444444444444444444444444444444'; tag = 'v1.2.5'; message = 'Completed later legacy baseline.' object = [pscustomobject]@{ type = 'commit'; sha = '2222222222222222222222222222222222222222' } } - Release = [pscustomobject]@{ + Release = [pscustomobject]@{ tag_name = 'v1.2.5'; prerelease = $false; draft = $false assets = @([pscustomobject]@{ name = 'TestModule-1.2.5.zip'; state = 'uploaded' }) } @@ -280,15 +280,15 @@ Describe 'Gallery-only version selection requires original ownership proof' { assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) } $other = [pscustomobject]@{ - Reference = [pscustomobject]@{ - ref = 'refs/tags/release-1.2.4' + Reference = [pscustomobject]@{ + ref = 'refs/tags/release-1.2.4' object = [pscustomobject]@{ type = 'tag'; sha = '4444444444444444444444444444444444444444' } } Annotation = [pscustomobject]@{ sha = '4444444444444444444444444444444444444444'; tag = 'release-1.2.4'; message = 'Another completed legacy baseline.' object = [pscustomobject]@{ type = 'commit'; sha = '2222222222222222222222222222222222222222' } } - Release = [pscustomobject]@{ + Release = [pscustomobject]@{ tag_name = 'release-1.2.4'; prerelease = $false; draft = $false assets = @([pscustomobject]@{ name = 'TestModule-1.2.4.zip'; state = 'uploaded' }) } diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 20eaa1a1..c9217bf7 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -98,11 +98,16 @@ publications without proof require reviewed forward-only recovery, not a guessed 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. -Stable recovery resolves the original version in Plan, before Build, by finding the actual tag claim for that -immutable source and producing context. Its raw Gallery package must match the recorded tested file tree. +Stable recovery resolves the original version in Plan, before Build, by finding the release-tag and shared +module/version claims for that immutable source and producing context. Refs for the same immutable claim are +deduplicated. An unpublished shared-only claim stops for original-job retry rather than selecting a new version +after other releases advance. Its raw Gallery package must match the recorded tested file tree. Latest-version hints and numeric equality do not establish ownership. A changed source, foreign PR/context, ambiguous claim, unavailable legacy proof, or API failure stops resolution without inventing a fallback version. +For a new source, baseline completeness uses its actual historical tag/release, even when the configured prefix +changes. Recovery and new claims still bind the current prefix exactly; ambiguous completed baseline tags fail. + If Gallery publication succeeded and GitHub creation or upload failed, the retry retains that proven version, skips only the identical Gallery upload, and completes the missing GitHub work without replacing bytes. Build and tests still run, and the publisher rechecks the resulting artifact against the original claim. From 53b32f3c4e0124d0036b3d161be57a9ddc977f1b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:02:07 +0100 Subject: [PATCH 24/27] 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. (cherry picked from commit 3faf9fb1315471a3b11f62c1856b914fd263dd19) --- .../Publish-PSModule.Preflight.Tests.ps1 | 107 ++++++++++++++++++ .../tests/Publish-PSModule.TestFixture.psm1 | 5 +- .../Release-PSModule.Ownership.Tests.ps1 | 13 +++ 3 files changed, 124 insertions(+), 1 deletion(-) 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 c9217bf7..3b6affbb 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 c47584b356d80dc4e280505d30e9b3d267cd65d1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:17:27 +0100 Subject: [PATCH 26/27] Reproduce projected resolver tag collection metadata (cherry picked from commit b367050dc8984b8acac56dd1d91488a528e8723a) --- .../tests/Publish-PSModule.Preflight.Tests.ps1 | 17 +++++++++++++++++ 1 file changed, 17 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..52d10f1f 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -243,6 +243,23 @@ Describe 'Publish-PSModule source-bound preflight' { $response.Value.Count | Should -Be 0 } + It 'rejects a scalar tag enumeration instead of projecting a discovery collection' { + $script:state.ApiResponses['repos/PSModule/TestModule/git/matching-refs/tags/'] = [pscustomobject]@{ + ref = 'refs/tags/v1.2.4' + object = [pscustomobject]@{ type = 'commit'; sha = $env:GITHUB_SHA } + } + $reader = Get-PSModulePublicationReader -Repository 'PSModule/TestModule' -ReadOnly + + { & $reader.GetTagList } | Should -Throw '*collection metadata*' + } + + It 'retains a verified empty discovery collection for a new module' { + $script:state.ApiResponses['repos/PSModule/TestModule/git/matching-refs/tags/'] = @() + $reader = Get-PSModulePublicationReader -Repository 'PSModule/TestModule' -ReadOnly + + @(& $reader.GetTagList).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' } From e2e282a584b62a6a0414210c7bba72e24595977f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:19:14 +0100 Subject: [PATCH 27/27] Require a verified tag collection for source claim discovery Keep verified empty collections valid and reject projected scalar metadata before source-bound recovery selection. (cherry picked from commit ae7c3334aed87c30578a84efc89022cc87b6462d) --- .../Publish-PSModule/src/Publish-PSModule.Helpers.psm1 | 6 +++++- .../tests/Publish-PSModule.Preflight.Tests.ps1 | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index 0832e047..c424c58e 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -338,7 +338,11 @@ function Get-PSModulePublicationReader { }.GetNewClosure() GetTagReference = $readTagReference GetTagList = { - (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/matching-refs/tags/").Value + $value = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/matching-refs/tags/").Value + if ($value -isnot [array]) { + throw 'The publication tag enumeration returned unverifiable collection metadata.' + } + $value }.GetNewClosure() GetVersionReference = { param($Name, $Version) 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 52d10f1f..d6c7ee36 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -245,7 +245,7 @@ Describe 'Publish-PSModule source-bound preflight' { It 'rejects a scalar tag enumeration instead of projecting a discovery collection' { $script:state.ApiResponses['repos/PSModule/TestModule/git/matching-refs/tags/'] = [pscustomobject]@{ - ref = 'refs/tags/v1.2.4' + ref = 'refs/tags/v1.2.4' object = [pscustomobject]@{ type = 'commit'; sha = $env:GITHUB_SHA } } $reader = Get-PSModulePublicationReader -Repository 'PSModule/TestModule' -ReadOnly