Skip to content

Commit 8ed2377

Browse files
Require terminal ownership proof and typed publication metadata
Reuse the provenance footer for stable PR releases, preserve raw API object and collection shapes, and share typed immutable tag reads. Escape module-name dots without altering Gallery names or payload metadata.
1 parent 3faf9fb commit 8ed2377

7 files changed

Lines changed: 190 additions & 73 deletions

File tree

‎.github/actions/Publish-PSModule/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
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.
44

5-
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/<lowercase-module>/<lowercase-version>`, independently of the configured release-tag prefix. The shared coordinate and release tag reference the same immutable annotated source/context/file-tree/ZIP claim. Existing tags are never updated or deleted; the package ownership coordinate is retained for immutable Gallery proof.
5+
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/<escaped-lowercase-module>/<lowercase-version>`, 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.
66

77
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.
88

99
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.
1010

1111
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.
1212

13+
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.
14+
1315
## Inputs
1416

1517
| Name | Description | Required | Default |

‎.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1‎

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ function Invoke-PSModulePublicationApi {
128128
throw "The GitHub API response for [$Endpoint] has no verifiable JSON body."
129129
}
130130
$body = $parts[1] -replace '(?m)^gh:.*(?:\r?\n|\z)', ''
131-
$value = $body | ConvertFrom-Json -ErrorAction Stop
131+
$value = ConvertFrom-Json -InputObject $body -NoEnumerate -ErrorAction Stop
132132
if ($null -eq $value) {
133133
throw "The GitHub API response for [$Endpoint] returned no resource metadata."
134134
}
@@ -302,60 +302,67 @@ function Get-PSModulePublicationReader {
302302

303303
Write-Verbose "Binding publication readers to [$Repository]."
304304
$requiresWriteAccess = -not $ReadOnly
305+
$readTagReference = {
306+
param($Tag)
307+
$encodedTag = [Uri]::EscapeDataString($Tag)
308+
$response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/ref/tags/$encodedTag" -AllowMissing
309+
if ($null -eq $response) { return }
310+
$value = $response.Value
311+
if ($value -isnot [PSCustomObject] -or $value -is [array] -or
312+
$value.ref -isnot [string] -or $value.ref -cne "refs/tags/$Tag" -or
313+
$value.object -isnot [PSCustomObject] -or $value.object -is [array] -or
314+
$value.object.sha -isnot [string] -or $value.object.sha -notmatch '^[0-9a-f]{40}$' -or
315+
$value.object.type -isnot [string] -or $value.object.type -cnotin @('tag', 'commit')) {
316+
throw "The tag lookup for [$Tag] returned unverifiable reference metadata."
317+
}
318+
$value.object
319+
}.GetNewClosure()
305320
@{
306321
GetSource = {
307322
param($CommitSha)
308323
$repositoryMetadata = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository").Value
309-
if ($repositoryMetadata.full_name -ine $Repository) {
324+
if ($repositoryMetadata -isnot [PSCustomObject] -or $repositoryMetadata -is [array] -or
325+
$repositoryMetadata.full_name -isnot [string] -or $repositoryMetadata.full_name -ine $Repository) {
310326
throw "Cannot establish access to the exact consumer repository [$Repository]."
311327
}
312-
if ($requiresWriteAccess -and $repositoryMetadata.permissions.push -ne $true) {
328+
if ($requiresWriteAccess -and ($repositoryMetadata.permissions.push -isnot [bool] -or
329+
$repositoryMetadata.permissions.push -ne $true)) {
313330
throw "Cannot verify release visibility and write access in [$Repository]. Refusing to treat hidden releases as absent."
314331
}
315332
$commit = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/commits/$CommitSha").Value
316-
if ($commit.sha -cne $CommitSha) {
333+
if ($commit -isnot [PSCustomObject] -or $commit -is [array] -or
334+
$commit.sha -isnot [string] -or $commit.sha -cne $CommitSha) {
317335
throw "Cannot verify the original immutable source [$CommitSha] in [$Repository]."
318336
}
319337
[string]$commit.sha
320338
}.GetNewClosure()
321-
GetTagReference = {
322-
param($Tag)
323-
$encodedTag = [Uri]::EscapeDataString($Tag)
324-
$response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/ref/tags/$encodedTag" -AllowMissing
325-
if ($null -eq $response) {
326-
return
327-
}
328-
if ($response.Value.ref -cne "refs/tags/$Tag" -or $null -eq $response.Value.object) {
329-
throw "The tag lookup for [$Tag] did not return the exact tag reference."
330-
}
331-
$response.Value.object
332-
}.GetNewClosure()
339+
GetTagReference = $readTagReference
333340
GetVersionReference = {
334341
param($Name, $Version)
335342
$tag = Get-PSModulePublicationVersionTag -ModuleName $Name -GalleryVersion $Version
336-
$encodedTag = [Uri]::EscapeDataString($tag)
337-
$response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/ref/tags/$encodedTag" -AllowMissing
338-
if ($null -eq $response) {
339-
return
340-
}
341-
if ($response.Value.ref -cne "refs/tags/$tag" -or $null -eq $response.Value.object) {
342-
throw "Cannot verify the shared module/version ownership coordinate [$tag]."
343-
}
344-
$response.Value.object
343+
& $readTagReference $tag
345344
}.GetNewClosure()
346345
GetTagObject = {
347346
param($Sha)
348347
$response = (Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/git/tags/$Sha").Value
349-
if ($response.sha -cne $Sha -or $null -eq $response.object -or
350-
$response.PSObject.Properties.Name -notcontains 'message') {
348+
if ($response -isnot [PSCustomObject] -or $response -is [array] -or
349+
$response.sha -isnot [string] -or $response.sha -cne $Sha -or $response.tag -isnot [string] -or
350+
$response.object -isnot [PSCustomObject] -or $response.object -is [array] -or
351+
$response.object.type -isnot [string] -or $response.object.sha -isnot [string] -or
352+
$response.object.sha -notmatch '^[0-9a-f]{40}$' -or $response.message -isnot [string]) {
351353
throw "Cannot verify the annotated publication tag [$Sha]."
352354
}
353355
$response
354356
}.GetNewClosure()
355357
GetRelease = {
356358
param($Tag)
357359
$encodedTag = [Uri]::EscapeDataString($Tag)
358-
(Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/releases/tags/$encodedTag" -AllowMissing).Value
360+
$response = Invoke-PSModulePublicationApi -Endpoint "repos/$Repository/releases/tags/$encodedTag" -AllowMissing
361+
if ($null -eq $response) { return }
362+
if ($response.Value -isnot [PSCustomObject] -or $response.Value -is [array]) {
363+
throw "The release lookup for [$Tag] returned unverifiable resource metadata."
364+
}
365+
$response.Value
359366
}.GetNewClosure()
360367
GetPackage = {
361368
param($Name, $Version)
@@ -510,7 +517,7 @@ function Get-PSModulePublicationState {
510517
throw "Cannot verify the existing release context for [$($expected.ReleaseTag)]."
511518
}
512519
$owner = Get-PSModulePrereleaseOwnership -Body $release.body
513-
if ($null -ne $owner -or ($expected.ReleaseType -ceq 'Prerelease' -and $expected.PullRequest -gt 0)) {
520+
if ($null -ne $owner -or $expected.PullRequest -gt 0) {
514521
if ($expected.PullRequest -lt 1) {
515522
throw 'The existing release ownership footer conflicts with the direct-release context.'
516523
}
@@ -522,6 +529,9 @@ function Get-PSModulePublicationState {
522529
throw 'The existing release contains foreign or ambiguous module assets.'
523530
}
524531
if ($assets.Count -eq 1) {
532+
if ($assets[0].name -isnot [string] -or $assets[0].digest -isnot [string] -or $assets[0].state -isnot [string]) {
533+
throw 'The existing module asset has unverifiable byte-proof metadata.'
534+
}
525535
$digest = [regex]::Match([string]$assets[0].digest, '^sha256:([0-9A-Fa-f]{64})$')
526536
if (-not $digest.Success -or $assets[0].state -cne 'uploaded') {
527537
throw "The existing asset [$($expected.AssetName)] has no verifiable published-byte proof."
@@ -615,7 +625,10 @@ function New-PSModulePublicationTag {
615625
}
616626
$tag = (Invoke-PSModulePublicationApi -Endpoint "repos/$($Claim.Repository)/git/tags" `
617627
-Method POST -Field $tagFields).Value
618-
if ($tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -cne $Claim.ReleaseTag -or
628+
if ($tag -isnot [PSCustomObject] -or $tag -is [array] -or
629+
$tag.sha -isnot [string] -or $tag.sha -notmatch '^[0-9a-f]{40}$' -or $tag.tag -isnot [string] -or
630+
$tag.message -isnot [string] -or $tag.object.sha -isnot [string] -or $tag.object.type -isnot [string] -or
631+
$tag.tag -cne $Claim.ReleaseTag -or
619632
$tag.object.type -cne 'commit' -or $tag.object.sha -cne $Claim.CommitSha -or $tag.message -cne $message) {
620633
throw 'The annotated object did not preserve the exact source/context/artifact claim.'
621634
}

‎.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,11 +183,15 @@ Describe 'Publish-PSModule source-bound preflight' {
183183
@{ Resource = 'repository array' }
184184
@{ Resource = 'commit array' }
185185
@{ Resource = 'string write permission' }
186+
@{ Resource = 'release ref array' }
187+
@{ Resource = 'annotation target array' }
188+
@{ Resource = 'release array' }
189+
@{ Resource = 'asset digest array' }
186190
) {
187191
if ($Resource -ceq 'commit array') {
188192
$endpoint = "repos/PSModule/TestModule/git/commits/$env:GITHUB_SHA"
189193
$script:state.ApiResponses[$endpoint] = @([pscustomobject]@{ sha = $env:GITHUB_SHA })
190-
} else {
194+
} elseif ($Resource -in @('repository array', 'string write permission')) {
191195
$metadata = [pscustomobject]@{
192196
full_name = 'PSModule/TestModule'; permissions = [pscustomobject]@{ push = $true }
193197
}
@@ -197,14 +201,37 @@ Describe 'Publish-PSModule source-bound preflight' {
197201
} else {
198202
$script:state.ApiResponses['repos/PSModule/TestModule'] = @($metadata)
199203
}
204+
} else {
205+
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
206+
if ($Resource -ceq 'release ref array') {
207+
$script:state.ApiResponses['repos/PSModule/TestModule/git/ref/tags/v1.2.4'] = @([pscustomobject]@{
208+
ref = 'refs/tags/v1.2.4'; object = $script:state.Tag
209+
})
210+
} elseif ($Resource -ceq 'annotation target array') {
211+
$script:state.TagObject.object = @([pscustomobject]@{ type = 'commit'; sha = $env:GITHUB_SHA })
212+
} else {
213+
$release = [pscustomobject]@{
214+
tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4'
215+
prerelease = $false; draft = $false; body = 'Direct release notes.'; assets = @()
216+
}
217+
if ($Resource -ceq 'release array') {
218+
$script:state.ApiResponses['repos/PSModule/TestModule/releases/tags/v1.2.4'] = @($release)
219+
} else {
220+
$release.assets = @([pscustomobject]@{
221+
name = 'TestModule-1.2.4.zip'; state = 'uploaded'
222+
digest = @("sha256:$($script:state.ExpectedClaim.AssetSha256)")
223+
})
224+
$script:state.Release = $release
225+
}
226+
}
200227
}
201228

202229
$failure = $null
203230
try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ }
204231

205232
$script:state.GalleryWrites | Should -Be 0
206233
$script:state.GitHubWrites | Should -Be 0
207-
$failure.Exception.Message | Should -Match 'metadata|access|source'
234+
$failure.Exception.Message | Should -Match 'metadata|access|source|tag'
208235
}
209236

210237
It 'retains a verified empty tag enumeration instead of treating it as an unknown JSON response' {
@@ -582,6 +609,65 @@ Describe 'Publish-PSModule source-bound preflight' {
582609
$script:state.Release.body | Should -BeExactly $originalNotes
583610
}
584611

612+
It 'rejects array-valued release <Field> metadata before any claim or upload' -ForEach @(
613+
@{ Field = 'Title' }
614+
@{ Field = 'Body' }
615+
@{ Field = 'HeadRef' }
616+
) {
617+
$context = @{ Number = 42 }
618+
$context[$Field] = @('Unverifiable projected metadata.')
619+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = $context | ConvertTo-Json -Compress
620+
621+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*metadata*'
622+
623+
$script:state.GalleryWrites | Should -Be 0
624+
$script:state.GitHubWrites | Should -Be 0
625+
}
626+
627+
It 'rejects <Shape> generated stable notes before release creation or upload' -ForEach @(
628+
@{ Shape = 'array-valued body' }
629+
@{ Shape = 'top-level array' }
630+
) {
631+
$script:state.ExpectedClaim.PullRequest = 42
632+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '{"Number":42}'
633+
[IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, '{"head_commit":{"message":""}}')
634+
Mock -CommandName git -MockWith {
635+
$global:LASTEXITCODE = 0
636+
''
637+
}
638+
$notes = [pscustomobject]@{ body = 'Complete notes.' }
639+
if ($Shape -ceq 'array-valued body') {
640+
$notes.body = @('Incomplete projected notes.')
641+
$script:state.ApiResponses['repos/PSModule/TestModule/releases/generate-notes'] = $notes
642+
} else {
643+
$script:state.ApiResponses['repos/PSModule/TestModule/releases/generate-notes'] = @($notes)
644+
}
645+
646+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*notes*'
647+
648+
Should -Invoke -CommandName git -Times 1 -Exactly
649+
($script:state.Calls -join "`n") | Should -Match 'releases/generate-notes'
650+
$script:state.GalleryWrites | Should -Be 0
651+
$script:state.GitHubCreationUpload | Should -Be 0
652+
}
653+
654+
It 'retains complete generated stable notes before the producing-context footer' {
655+
$script:state.ExpectedClaim.PullRequest = 42
656+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '{"Number":42}'
657+
[IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, '{"head_commit":{"message":""}}')
658+
Mock -CommandName git -MockWith {
659+
$global:LASTEXITCODE = 0
660+
''
661+
}
662+
663+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Not -Throw
664+
665+
Should -Invoke -CommandName git -Times 1 -Exactly
666+
($script:state.Calls -join "`n") | Should -Match 'releases/generate-notes'
667+
$script:state.Release.body | Should -Match '^Complete independently generated fixture notes\.'
668+
(Get-PSModulePrereleaseOwnership -Body $script:state.Release.body).PullRequest | Should -Be 42
669+
}
670+
585671
It 'rejects a disappeared previously claimed tag rather than recreating it' {
586672
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
587673
$script:state.OnTagRead = {

‎.github/actions/Release-PSModule/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ Release creation and upload recheck the release tag, shared module/version coord
5050

5151
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.
5252

53-
Complete authored or generated prerelease notes precede a final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release. Legacy records without proof require reviewed forward-only recovery; the action does not fabricate provenance or alter consumer metadata.
53+
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.

0 commit comments

Comments
 (0)