diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md index 99602dfb..e685d4ad 100644 --- a/.github/actions/Publish-PSModule/README.md +++ b/.github/actions/Publish-PSModule/README.md @@ -1,6 +1,18 @@ # 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 Gallery module/version is reserved at `PSModule-Publication//`, independently of the configured release-tag prefix. Module-name dots are encoded as `%2e` so valid dotted names cannot produce Git-invalid `..` or `.lock` components. 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. + +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. + +GitHub resource responses must have valid object/scalar shapes, while verified empty tag collections remain valid absence. An existing PR-produced stable or prerelease release requires its producing-context footer; unreadable terminal markers are errors even when indented or inline. Unknown legacy PR release context is not inferred from a matching tag. + +Existing releases must explicitly return scalar boolean `draft: false`. Missing, null, string, or array status is unknown published-release proof and stops before Gallery publication or a resumed asset upload. ## Inputs @@ -10,6 +22,10 @@ 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 | +| `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` | | `WorkingDirectory` | Directory where the publishing script runs. | No | `.` | @@ -27,6 +43,14 @@ 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 }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} + 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. + +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/Publish-PSModule/action.yml b/.github/actions/Publish-PSModule/action.yml index d21604d1..4595849b 100644 --- a/.github/actions/Publish-PSModule/action.yml +++ b/.github/actions/Publish-PSModule/action.yml @@ -17,6 +17,17 @@ 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 + VersionPrefix: + description: Configured prefix used by the existing resolver; verifies the tag against the tested module version. + required: false + default: v + 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 +66,8 @@ 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_VersionPrefix: ${{ inputs.VersionPrefix }} + 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-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index da6aa7f8..9479ccc0 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,619 @@ "$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 = ConvertFrom-Json -InputObject $body -NoEnumerate -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 + $readTagReference = { + param($Tag) + $encodedTag = [Uri]::EscapeDataString($Tag) + $response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/ref/tags/$encodedTag" -AllowMissing + if ($null -eq $response) { return } + $value = $response.Value + if ($value -isnot [PSCustomObject] -or $value -is [array] -or + $value.ref -isnot [string] -or $value.ref -cne "refs/tags/$Tag" -or + $value.object -isnot [PSCustomObject] -or $value.object -is [array] -or + $value.object.sha -isnot [string] -or $value.object.sha -notmatch '^[0-9a-f]{40}$' -or + $value.object.type -isnot [string] -or $value.object.type -cnotin @('tag', 'commit')) { + throw "The tag lookup for [$Tag] returned unverifiable reference metadata." + } + $value.object + }.GetNewClosure() + @{ + GetSource = { + param($CommitSha) + $repositoryMetadata = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository").Value + if ($repositoryMetadata -isnot [PSCustomObject] -or $repositoryMetadata -is [array] -or + $repositoryMetadata.full_name -isnot [string] -or $repositoryMetadata.full_name -ine $Repository) { + throw "Cannot establish access to the exact consumer repository [$Repository]." + } + if ($requiresWriteAccess -and ($repositoryMetadata.permissions.push -isnot [bool] -or + $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 -isnot [PSCustomObject] -or $commit -is [array] -or + $commit.sha -isnot [string] -or $commit.sha -cne $CommitSha) { + throw "Cannot verify the original immutable source [$CommitSha] in [$Repository]." + } + [string]$commit.sha + }.GetNewClosure() + GetTagReference = $readTagReference + GetVersionReference = { + param($Name, $Version) + $tag = Get-PSModulePublicationVersionTag -ModuleName $Name -GalleryVersion $Version + & $readTagReference $tag + }.GetNewClosure() + GetTagObject = { + param($Sha) + $response = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/tags/$Sha").Value + if ($response -isnot [PSCustomObject] -or $response -is [array] -or + $response.sha -isnot [string] -or $response.sha -cne $Sha -or $response.tag -isnot [string] -or + $response.object -isnot [PSCustomObject] -or $response.object -is [array] -or + $response.object.type -isnot [string] -or $response.object.sha -isnot [string] -or + $response.object.sha -notmatch '^[0-9a-f]{40}$' -or $response.message -isnot [string]) { + throw "Cannot verify the annotated publication tag [$Sha]." + } + $response + }.GetNewClosure() + GetRelease = { + param($Tag) + $encodedTag = [Uri]::EscapeDataString($Tag) + $response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/releases/tags/$encodedTag" -AllowMissing + if ($null -eq $response) { return } + if ($response.Value -isnot [PSCustomObject] -or $response.Value -is [array]) { + throw "The release lookup for [$Tag] returned unverifiable resource metadata." + } + $response.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 + ) + + $readerNames = @( + 'GetSource', 'GetTagReference', 'GetVersionReference', 'GetTagObject', + 'GetRelease', 'GetPackage', 'GetPackageArtifactDigest' + ) + foreach ($name in $readerNames) { + 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 + $versionTag = Get-PSModulePublicationVersionTag -ModuleName $expected.ModuleName -GalleryVersion $expected.GalleryVersion + $versionEvidence = @{ Reference = $null; Annotation = $null } + $versionParameters = @{ + Tag = $versionTag + AllowMissing = $true + GetTagReference = { + param($Tag) + if ($Tag -cne $versionTag) { throw 'Unexpected package ownership coordinate.' } + $versionEvidence.Reference = & $Reader.GetVersionReference $expected.ModuleName $expected.GalleryVersion + $versionEvidence.Reference + }.GetNewClosure() + GetTagObject = { + param($Sha) + $annotation = & $Reader.GetTagObject $Sha + if ($null -eq $versionEvidence.Annotation) { $versionEvidence.Annotation = $annotation } + $annotation.object + }.GetNewClosure() + } + $versionCommit = Resolve-PSModuleReleaseTagCommit @versionParameters + if ($null -ne $versionCommit -and $versionCommit -cne $expected.CommitSha) { + throw "Module/version coordinate [$versionTag] belongs to another source [$versionCommit]." + } + $versionClaim = Get-PSModulePublicationClaim -Message $versionEvidence.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." + } + } elseif ($null -eq $claim -or $tagEvidence.Annotation.tag -cne $expected.ReleaseTag) { + throw "The existing tag [$($expected.ReleaseTag)] has no verifiable publication ownership proof." + } + if ($null -ne $versionCommit -and + ($null -eq $versionClaim -or $versionEvidence.Annotation.tag -cne $expected.ReleaseTag)) { + throw "The module/version coordinate [$versionTag] has no verifiable publication proof." + } + if ($null -ne $package -and $null -eq $versionCommit) { + throw "The existing Gallery package [$($expected.GalleryVersion)] has no shared module/version ownership proof." + } + if ($null -ne $tagCommit -and $null -ne $versionCommit -and + $tagEvidence.Reference.sha -cne $versionEvidence.Reference.sha) { + throw 'The release tag and module/version coordinate reference different immutable claims.' + } + foreach ($proof in @($claim, $versionClaim) | Where-Object { $null -ne $_ }) { + if ($proof.Repository -ine $expected.Repository -or $proof.PullRequest -ne $expected.PullRequest) { + throw 'The publication claim belongs to a different repository or producing pull request.' + } + foreach ($name in @('CommitSha', 'ModuleName', 'GalleryVersion', 'VersionPrefix', 'ReleaseTag', 'ReleaseType', 'AssetName')) { + if ($proof.$name -cne $expected.$name) { + throw "The publication claim has conflicting [$name] ownership." + } + } + if ($proof.ArtifactSha256 -cne $expected.ArtifactSha256 -or $proof.AssetSha256 -cne $expected.AssetSha256) { + throw 'The claimed version belongs to different tested artifact or release asset contents.' + } + } + if ($null -ne $release) { + if ($release.draft -isnot [bool] -or $release.draft -ne $false) { + throw 'The existing release has no verified boolean draft:false status. Refusing publication or an incomplete release.' + } + if ($release.tag_name -cne $expected.ReleaseTag -or + [string]::IsNullOrWhiteSpace($release.html_url) -or + $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.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) { + if ($assets[0].name -isnot [string] -or $assets[0].digest -isnot [string] -or $assets[0].state -isnot [string]) { + throw 'The existing module asset has unverifiable byte-proof metadata.' + } + $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 + VersionTagSha = $versionEvidence.Reference.sha + Claim = if ($null -ne $versionClaim) { $versionClaim } else { $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 $State.TagSha -and $current.TagSha -cne $State.TagSha) { + throw 'The existing publication tag changed or disappeared after preflight. Refusing Gallery publication.' + } + if ($null -ne $State.VersionTagSha -and $current.VersionTagSha -cne $State.VersionTagSha) { + throw 'The shared module/version claim changed or disappeared after preflight. Refusing publication.' + } + if ($null -ne $current.TagSha -and $null -ne $current.VersionTagSha) { + return $current + } + if (-not $PSCmdlet.ShouldProcess("$($Claim.Repository):$($Claim.ModuleName):$($Claim.GalleryVersion)", + 'Create shared module/version ownership and immutable release tag')) { + return + } + $claimSha = $current.VersionTagSha + if ($null -eq $claimSha) { + $claimSha = $current.TagSha + } + 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 -isnot [PSCustomObject] -or $tag -is [array] -or + $tag.sha -isnot [string] -or $tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -isnot [string] -or + $tag.message -isnot [string] -or $tag.object.sha -isnot [string] -or $tag.object.type -isnot [string] -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 + $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.' + } + $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.' + } + $verified +} diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 51095015..2d4808d2 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,8 +44,21 @@ 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 + $versionPrefix = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix + $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 "Version prefix: [$versionPrefix]" Write-Host "Module path: [$modulePath]" Write-Host "WhatIf: [$whatIf]" } @@ -46,19 +67,25 @@ LogGroup 'Load inputs' { #region Load release context LogGroup 'Load release context' { $pullRequestJson = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest - $pullRequest = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { - $pullRequestJson | ConvertFrom-Json + $pullRequest = $null + if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { + $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) { - [pscustomobject]@{ - Number = $githubEvent.pull_request.number + $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 = $eventPullRequest.number } } } - $prNumber = $pullRequest.Number + $prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest if ($prNumber) { Write-Host "Pull request: [#$prNumber]" } else { @@ -130,58 +157,96 @@ 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 + VersionPrefix = $versionPrefix + ReleaseTag = $releaseTag + ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } + ArtifactSha256 = $artifactDigest + 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 + } + } -#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 + $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.' + } + 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 new file mode 100644 index 00000000..1a1e13e8 --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1 @@ -0,0 +1,955 @@ +[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 + 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( + [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' + '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_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) { + $script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + } + $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 + $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 { + $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 @' +@{ + 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 $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' + $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' + } + + 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' { + 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 + tag_name = 'v1.2.4' + html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false + draft = $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 = "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 = $_ } + + $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 malformed response before publication' -ForEach @( + @{ Resource = 'repository array' } + @{ Resource = 'commit array' } + @{ Resource = 'string write permission' } + @{ Resource = 'release ref array' } + @{ Resource = 'annotation target array' } + @{ Resource = 'release array' } + @{ Resource = 'asset digest array' } + ) { + if ($Resource -ceq 'commit array') { + $endpoint = "repos/PSModule/TestModule/git/commits/$env:GITHUB_SHA" + $script:state.ApiResponses[$endpoint] = @([pscustomobject]@{ sha = $env:GITHUB_SHA }) + } elseif ($Resource -in @('repository array', 'string write permission')) { + $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) + } + } else { + Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim + if ($Resource -ceq 'release ref array') { + $script:state.ApiResponses['repos/PSModule/TestModule/git/ref/tags/v1.2.4'] = @([pscustomobject]@{ + ref = 'refs/tags/v1.2.4'; object = $script:state.Tag + }) + } elseif ($Resource -ceq 'annotation target array') { + $script:state.TagObject.object = @([pscustomobject]@{ type = 'commit'; sha = $env:GITHUB_SHA }) + } else { + $release = [pscustomobject]@{ + tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4' + prerelease = $false; draft = $false; body = 'Direct release notes.'; assets = @() + } + if ($Resource -ceq 'release array') { + $script:state.ApiResponses['repos/PSModule/TestModule/releases/tags/v1.2.4'] = @($release) + } else { + $release.assets = @([pscustomobject]@{ + name = 'TestModule-1.2.4.zip'; state = 'uploaded' + digest = @("sha256:$($script:state.ExpectedClaim.AssetSha256)") + }) + $script:state.Release = $release + } + } + } + + $failure = $null + try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ } + + $script:state.GalleryWrites | Should -Be 0 + $script:state.GitHubWrites | Should -Be 0 + $failure.Exception.Message | Should -Match 'metadata|access|source|tag' + } + + 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' } + + $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 = @{ + sha = $script:state.Tag.sha + tag = 'v1.2.4' + message = 'Legacy annotation.' + 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 + } + + 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 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' -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 { + $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() + } + } + + $controllerPath = $script:publishScriptPath + if ($Controller -ceq 'release') { $controllerPath = $script:releaseScriptPath } + $failure = $null + 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 + $script:state.GitHubWrites | Should -Be 0 + $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' } + $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 + draft = $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' + } + + 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]@{ + 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 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('" + object = @{ type = 'commit'; sha = $Claim.CommitSha } + } + $State.VersionTag = $State.Tag + $State.VersionTagObject = $State.TagObject +} + +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', + + # The existing resolver prefix used by this fixture. + [Parameter()] + [string] $VersionPrefix = 'v' + ) + + 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 + VersionTag = $null + VersionTagObject = $null + NewTagObject = $null + Release = $null + Package = $null + RawPackage = '' + ApiError = '' + ApiResponses = @{} + TagReads = 0 + OnTagRead = $null + OnTagCreate = $null + OnVersionTagCreate = $null + OnGeneratedNotes = $null + FailReleaseCreate = $false + ExpectedClaim = [pscustomobject]@{ + Repository = "PSModule/$name" + PullRequest = $PullRequest + CommitSha = $CommitSha + ModuleName = $name + GalleryVersion = $Version + VersionPrefix = $VersionPrefix + 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 ($state.ApiResponses.ContainsKey($endpoint)) { + $value = $state.ApiResponses[$endpoint] + } elseif ($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") { + $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 $existing) { + $status = 422 + $global:LASTEXITCODE = 1 + $value = @{ message = 'Reference already exists' } + } else { + $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") { + 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]." + } + } 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/*') { + $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/*') { + $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 { + throw "Unexpected read API in the publication fixture: [$endpoint]." + } + if ($null -eq $value) { + $status = 404 + $global:LASTEXITCODE = 1 + $value = @{ message = 'Not Found' } + } + $json = ConvertTo-Json -InputObject $value -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 +} diff --git a/.github/actions/Release-PSModule/README.md b/.github/actions/Release-PSModule/README.md index c86c9e30..4231cf05 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,9 @@ 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 | +| `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` | | `WorkingDirectory` | Directory where the release script runs. | No | `.` | | `UsePRTitleAsReleaseName` | Uses the pull request title as the release name. | No | `false` | @@ -36,6 +39,15 @@ 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 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 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 notes for PR-produced stable and prerelease releases precede the same final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release, and an existing PR release without that proof is not resumed. Indented, inline, or unreadable terminal ownership markers fail explicitly. Direct stable releases retain their genuinely absent PR context. 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/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 new file mode 100644 index 00000000..330c5070 --- /dev/null +++ b/.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1 @@ -0,0 +1,836 @@ +$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 ($null -ne $reference) { + if ($reference -is [array] -or + ($reference -isnot [PSCustomObject] -and $reference -isnot [Collections.IDictionary]) -or + $reference.type -isnot [string] -or $reference.sha -isnot [string]) { + throw "Cannot determine release source: tag [$Tag] has unverifiable reference metadata." + } + if ($reference.type -ne 'tag') { break } + 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 producing-release 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 producing-release 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) { + if ($Body -match '(?s)).)*PSModule\.PrereleaseOwner(?:(?!-->).)*(?:-->)?\s*\z') { + throw 'The published ownership footer is malformed or uses an unsupported provenance version.' + } + return + } + $bytes = [Convert]::FromBase64String($match.Groups[1].Value) + $ownership = ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)) -NoEnumerate -ErrorAction Stop + $number = 0L + 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)) { + 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-PSModuleProducingPullRequestNumber { + <# + .SYNOPSIS + Validate the producing PR identity or a genuinely absent direct-release context. + + .EXAMPLE + Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest + + Return a positive PR number, or zero only when no PR context was supplied. + + .INPUTS + None + + You can't pipe objects to Get-PSModuleProducingPullRequestNumber. + + .OUTPUTS + System.Int64 + + The validated producing PR number, or zero for absent context. + #> + [CmdletBinding()] + [OutputType([long])] + param( + # The normalized producing context, or null for a direct release. + [Parameter()] + [AllowNull()] + [object] $PullRequest + ) + + if ($null -eq $PullRequest) { + return 0L + } + $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.' + } + $number +} + +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 = 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 + $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 + $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.' + } + 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.' + } + [pscustomobject]@{ + Repository = [string]$claim.Repository + PullRequest = $number + 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() + 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) { + $relativePath = [IO.Path]::GetRelativePath($root, $file.FullName) + $relativePath = $relativePath.Replace([IO.Path]::DirectorySeparatorChar, [char]'/') + 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 + } + } +} + +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 + ) + + $moduleCoordinate = $ModuleName.ToLowerInvariant().Replace('.', '%2e') + "PSModule-Publication/$moduleCoordinate/$($GalleryVersion.ToLowerInvariant())" +} + +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-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 + 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 (Test-PSModulePackageMetadataPath -Path $relativePath) { + 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/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 27c1b73c..0fa8998b 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 '.+/' @@ -80,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]" @@ -93,19 +99,32 @@ 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 = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { - $pullRequestJson | ConvertFrom-Json - } elseif ($githubEvent.pull_request) { - [pscustomobject]@{ - Number = $githubEvent.pull_request.number - Title = $githubEvent.pull_request.title - Body = $githubEvent.pull_request.body - HeadRef = $githubEvent.pull_request.head.ref + $pullRequest = $null + if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { + $pullRequest = ConvertFrom-Json -InputObject $pullRequestJson -NoEnumerate -ErrorAction Stop + } 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 + 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]." } } - $prNumber = $pullRequest.Number $prHeadRef = $pullRequest.HeadRef $commitMessage = $githubEvent.head_commit.message if ([string]::IsNullOrWhiteSpace($commitMessage) -and -not [string]::IsNullOrWhiteSpace($commitSha)) { @@ -181,99 +200,186 @@ 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 + VersionPrefix = $versionPrefix + ReleaseTag = $releaseTag + ReleaseType = if ($createPrerelease) { 'Prerelease' } else { 'Release' } + ArtifactSha256 = Get-PSModuleArtifactDigest -Path $modulePath + 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) { + $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 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.' + } + $generatedBody = $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 { + $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.' + } + if ($null -ne $beforeCreate.Release) { + $releaseURL = $beforeCreate.Release.html_url + Write-Host 'An identical owned release appeared during notes preparation; retaining it.' + } 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 + $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.' } - 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.Ownership.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 new file mode 100644 index 00000000..9085f6d5 --- /dev/null +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.Ownership.Tests.ps1 @@ -0,0 +1,279 @@ +[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' + VersionPrefix = 'release-' + 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 'reserves a valid Git coordinate for module ' -ForEach @( + @{ Name = 'TestModule.lock' } + @{ Name = 'Test..Module' } + @{ Name = 'Microsoft.PowerShell.Management' } + ) { + $coordinate = Get-PSModulePublicationVersionTag -ModuleName $Name -GalleryVersion '1.2.4' + $gitPath = (Get-Command -Name git -CommandType Application | Select-Object -First 1).Source + + & $gitPath check-ref-format "refs/tags/$coordinate" + + $LASTEXITCODE | Should -Be 0 + } + + 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 = '' } + @{ Message = '' } + ) { + { 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' + 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 ([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'), + '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 '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' + + { Get-PSModuleArtifactDigest -Path $script:modulePath } | Should -Throw '*backslash*' + { New-PSModuleReleaseArchive -Path $script:modulePath -DestinationPath $archivePath } | Should -Throw '*backslash*' + Test-Path -LiteralPath $archivePath | Should -BeFalse + } + + It 'does not treat a raw backslash file record as a directory path' { + $record = [pscustomobject]@{ + RelativePath = 'data\value.txt' + Length = 1L + Hash = [byte[]](0..31) + } + + { Get-PSModuleContentDigest -File @($record) } | Should -Throw '*path*' + } + + 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*' + } +} 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..f702f17a 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' @@ -22,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' ) @@ -32,15 +41,17 @@ BeforeAll { } AfterAll { - foreach ($name in $script:environmentVariableNames) { - [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) - } - # 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 + 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() + } } Describe 'Release-PSModule WhatIf' { @@ -86,6 +97,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 @@ -95,40 +107,44 @@ 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 ` + -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 + draft = $false + 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 +177,116 @@ 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 + draft = $false + 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 + } + + It 'rejects supplied PR context without a positive identity before release writes' -ForEach @( + @{ Json = '{}' } + @{ Json = '[]' } + @{ Json = '[null]' } + @{ Json = '[{"Number":42}]' } + ) { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = $Json + + { & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*pull request*' + + $script:state.GitHubWrites | Should -Be 0 + } + + It 'revalidates a changed claim immediately before release creation after generated notes' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'false' + $script:state.OnGeneratedNotes = { + param($State) + $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.GitHubCreationUpload | Should -Be 0 + $script:state.Release | Should -BeNullOrEmpty + } } diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index ab9b8560..76755085 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,30 @@ function Get-NextPrereleaseNumber { Prerelease = $true Repository = 'PSGallery' Verbose = $false - ErrorAction = 'SilentlyContinue' + ErrorAction = 'Stop' + } + try { + $galleryVersions = @(Find-PSResource @params) + if ($galleryVersions.Count -eq 0) { + throw 'The successful prerelease enumeration returned no verifiable package metadata.' + } + $galleryVersions = @($galleryVersions | ForEach-Object { + if ($_.Name -ine $ModuleName -or [string]$_.Version -notmatch '^\d+\.\d+\.\d+$') { + throw "The Gallery prerelease enumeration returned unverifiable version metadata for [$ModuleName]." + } + [pscustomobject]@{ + Version = [version]([string]$_.Version) + Prerelease = $_.Prerelease + } + }) + } 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.Helpers.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 index 4a16a2ea..f0a27a23 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -8,6 +8,15 @@ param() BeforeAll { Import-Module -Name 'PSModule' -Force Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/Resolve-PSModuleVersion.Helpers.psm1') -Force + Mock -CommandName Find-PSResource -ModuleName Resolve-PSModuleVersion.Helpers -MockWith { + $errorRecord = [Management.Automation.ErrorRecord]::new( + [Exception]::new('The isolated fixture module is absent.'), + 'PackageNotFound,Find-PSResource', + [Management.Automation.ErrorCategory]::ObjectNotFound, + 'PSModuleNonExistentModuleForTesting' + ) + throw $errorRecord + } function Get-TestConfiguration { <# 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..273911bd --- /dev/null +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.ReadErrors.Tests.ps1 @@ -0,0 +1,105 @@ +[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 + } + + It 'rejects prerelease missing version metadata before filtering' { + $script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = $null; Prerelease = 'preview001' } + $parameters = @{ ModuleName = 'TestModule'; BaseVersion = '1.2.4'; PrereleaseName = 'preview'; Releases = @() } + + { Get-NextPrereleaseNumber @parameters } | Should -Throw '*metadata*' + } + + It 'rejects prerelease empty successful enumeration instead of treating it as verified absence' { + $parameters = @{ ModuleName = 'TestModule'; BaseVersion = '1.2.4'; PrereleaseName = 'preview'; Releases = @() } + + { Get-NextPrereleaseNumber @parameters } | Should -Throw '*metadata*' + } +} diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 4042920c..39e4664d 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -63,6 +63,9 @@ 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 }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} + CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} @@ -84,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 }} diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index fe226002..72c312d0 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -73,6 +73,35 @@ 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. +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. +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, +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 +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