Skip to content

Commit cb56b27

Browse files
Reject invalid producing context and changed claims before release mutations
1 parent 4cf52d8 commit cb56b27

7 files changed

Lines changed: 152 additions & 8 deletions

File tree

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,9 @@ function Get-PSModulePublicationState {
448448
}
449449
}
450450
if ($null -ne $release) {
451+
if ($release.draft -eq $true) {
452+
throw 'The existing release is a draft. Refusing Gallery publication or a success-shaped incomplete release.'
453+
}
451454
if ($release.tag_name -cne $expected.ReleaseTag -or
452455
[string]::IsNullOrWhiteSpace($release.html_url) -or
453456
$release.prerelease -isnot [bool] -or
@@ -532,10 +535,10 @@ function New-PSModulePublicationTag {
532535
)
533536

534537
$current = Get-PSModulePublicationState -ExpectedClaim $Claim -Reader $Reader
538+
if ($null -ne $State.TagSha -and $current.TagSha -cne $State.TagSha) {
539+
throw 'The existing publication tag changed or disappeared after preflight. Refusing Gallery publication.'
540+
}
535541
if ($null -ne $current.TagSha) {
536-
if ($null -ne $State.TagSha -and $current.TagSha -cne $State.TagSha) {
537-
throw 'The existing publication tag changed after preflight. Refusing Gallery publication.'
538-
}
539542
return $current
540543
}
541544
if (-not $PSCmdlet.ShouldProcess("$($Claim.Repository):$($Claim.ReleaseTag)", 'Create source-bound annotated version tag')) {

‎.github/actions/Publish-PSModule/src/publish.ps1‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ LogGroup 'Load release context' {
7777
}
7878
}
7979

80-
$prNumber = $pullRequest.Number
80+
$prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest
8181
if ($prNumber) {
8282
Write-Host "Pull request: [#$prNumber]"
8383
} else {

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,63 @@ Describe 'Publish-PSModule source-bound preflight' {
327327
$script:state.GitHubWrites | Should -Be 0
328328
$failure.Exception.Message | Should -Match 'pull request'
329329
}
330+
331+
It 'rejects an unreadable final ownership footer on a stable release' -ForEach @(
332+
@{ Footer = '<!-- PSModule.PrereleaseOwner:v2:e30= -->' }
333+
@{ Footer = '<!-- PSModule.PrereleaseOwner:v1:not-base64 -->' }
334+
) {
335+
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
336+
$script:state.Release = [pscustomobject]@{
337+
tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4'
338+
prerelease = $false; draft = $false; body = "Notes.`n$Footer"; assets = @()
339+
}
340+
341+
{ & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*footer*'
342+
343+
$script:state.GalleryWrites | Should -Be 0
344+
$script:state.GitHubWrites | Should -Be 0
345+
}
346+
347+
It 'rejects a disappeared previously claimed tag rather than recreating it' {
348+
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
349+
$script:state.OnTagRead = {
350+
param($State)
351+
if ($State.TagReads -eq 2) {
352+
$State.Tag = $null
353+
$State.TagObject = $null
354+
}
355+
}
356+
357+
{ & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*changed*'
358+
359+
$script:state.GalleryWrites | Should -Be 0
360+
$script:state.GitHubWrites | Should -Be 0
361+
}
362+
363+
It 'rejects supplied PR context without a positive identity before all writes' -ForEach @(
364+
@{ Json = '{}' }
365+
@{ Json = '{"Number":0}' }
366+
@{ Json = '{"Number":"unknown"}' }
367+
@{ Json = '42' }
368+
) {
369+
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = $Json
370+
371+
{ & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*pull request*'
372+
373+
$script:state.GalleryWrites | Should -Be 0
374+
$script:state.GitHubWrites | Should -Be 0
375+
}
376+
377+
It 'rejects a draft release before Gallery publication rather than reporting completed publication' {
378+
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
379+
$script:state.Release = [pscustomobject]@{
380+
tag_name = 'v1.2.4'; html_url = 'https://github.com/PSModule/TestModule/releases/tag/v1.2.4'
381+
prerelease = $false; draft = $true; body = 'Unpublished draft notes.'; assets = @()
382+
}
383+
384+
{ & $script:publishScriptPath 1>$null 6>$null } | Should -Throw '*draft*'
385+
386+
$script:state.GalleryWrites | Should -Be 0
387+
$script:state.GitHubWrites | Should -Be 0
388+
}
330389
}

‎.github/actions/Publish-PSModule/tests/Publish-PSModule.TestFixture.psm1‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ function New-PSModulePublicationFixture {
199199
TagReads = 0
200200
OnTagRead = $null
201201
OnTagCreate = $null
202+
OnGeneratedNotes = $null
202203
FailReleaseCreate = $false
203204
ExpectedClaim = [pscustomobject]@{
204205
Repository = "PSModule/$name"
@@ -312,6 +313,9 @@ function New-PSModulePublicationFixture {
312313
$status = 201
313314
}
314315
} elseif ($endpoint -ceq "repos/$($claim.Repository)/releases/generate-notes") {
316+
if ($null -ne $state.OnGeneratedNotes) {
317+
& $state.OnGeneratedNotes $state
318+
}
315319
$value = @{ body = 'Complete independently generated fixture notes.' }
316320
} else {
317321
throw "Unexpected create API in the publication fixture: [$endpoint]."

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ function Get-PSModulePrereleaseOwnership {
9999

100100
$match = [regex]::Match($Body, '(?:^|\r?\n)<!-- PSModule\.PrereleaseOwner:v1:([A-Za-z0-9+/=]+) -->\s*\z')
101101
if (-not $match.Success) {
102+
if ($Body -match '(?:^|\r?\n)<!-- PSModule\.PrereleaseOwner:[^\r\n]*\s*\z') {
103+
throw 'The published ownership footer is malformed or uses an unsupported provenance version.'
104+
}
102105
return
103106
}
104107
$bytes = [Convert]::FromBase64String($match.Groups[1].Value)
@@ -117,6 +120,46 @@ function Get-PSModulePrereleaseOwnership {
117120
}
118121
}
119122

123+
function Get-PSModuleProducingPullRequestNumber {
124+
<#
125+
.SYNOPSIS
126+
Validate the producing PR identity or a genuinely absent direct-release context.
127+
128+
.EXAMPLE
129+
Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest
130+
131+
Return a positive PR number, or zero only when no PR context was supplied.
132+
133+
.INPUTS
134+
None
135+
136+
You can't pipe objects to Get-PSModuleProducingPullRequestNumber.
137+
138+
.OUTPUTS
139+
System.Int64
140+
141+
The validated producing PR number, or zero for absent context.
142+
#>
143+
[CmdletBinding()]
144+
[OutputType([long])]
145+
param(
146+
# The normalized producing context, or null for a direct release.
147+
[Parameter()]
148+
[AllowNull()]
149+
[object] $PullRequest
150+
)
151+
152+
if ($null -eq $PullRequest) {
153+
return 0L
154+
}
155+
$number = 0L
156+
if (($PullRequest -isnot [PSCustomObject] -and $PullRequest -isnot [Collections.IDictionary]) -or
157+
-not [long]::TryParse([string]$PullRequest.Number, [ref]$number) -or $number -lt 1) {
158+
throw 'Supplied pull request context requires a positive producing pull request number.'
159+
}
160+
$number
161+
}
162+
120163
function Get-PSModuleReleaseOwnership {
121164
<#
122165
.SYNOPSIS

‎.github/actions/Release-PSModule/src/release.ps1‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ LogGroup 'Load release context' {
109109
HeadRef = $githubEvent.pull_request.head.ref
110110
}
111111
}
112-
$prNumber = $pullRequest.Number
112+
$prNumber = Get-PSModuleProducingPullRequestNumber -PullRequest $pullRequest
113113
$prHeadRef = $pullRequest.HeadRef
114114
$commitMessage = $githubEvent.head_commit.message
115115
if ([string]::IsNullOrWhiteSpace($commitMessage) -and -not [string]::IsNullOrWhiteSpace($commitSha)) {
@@ -294,9 +294,19 @@ LogGroup 'Create GitHub release' {
294294
Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')"
295295
$releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag"
296296
} else {
297-
$releaseURL = gh @releaseCreateCommand
298-
if ($LASTEXITCODE -ne 0) {
299-
throw "Failed to create the release [$releaseTag]."
297+
$beforeCreate = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader
298+
if ($beforeCreate.TagSha -cne $publicationState.TagSha -or
299+
(Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) {
300+
throw 'The source claim or tested artifact changed before release creation. Refusing publication.'
301+
}
302+
if ($null -ne $beforeCreate.Release) {
303+
$releaseURL = $beforeCreate.Release.html_url
304+
Write-Host 'An identical owned release appeared during notes preparation; retaining it.'
305+
} else {
306+
$releaseURL = gh @releaseCreateCommand
307+
if ($LASTEXITCODE -ne 0) {
308+
throw "Failed to create the release [$releaseTag]."
309+
}
300310
}
301311
}
302312
} finally {

‎.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,29 @@ Describe 'Release-PSModule WhatIf' {
255255
$script:state.GitHubWrites | Should -Be 0
256256
@($script:state.Release.assets).Count | Should -Be 0
257257
}
258+
259+
It 'rejects supplied PR context without a positive identity before release writes' {
260+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false'
261+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '{}'
262+
263+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*pull request*'
264+
265+
$script:state.GitHubWrites | Should -Be 0
266+
}
267+
268+
It 'revalidates a changed claim immediately before release creation after generated notes' {
269+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false'
270+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'false'
271+
$script:state.OnGeneratedNotes = {
272+
param($State)
273+
$foreign = $State.ExpectedClaim | ConvertTo-Json -Compress | ConvertFrom-Json
274+
$foreign.CommitSha = '2222222222222222222222222222222222222222'
275+
Set-PSModuleFixtureClaim -State $State -Claim $foreign -TagSha '4444444444444444444444444444444444444444'
276+
}
277+
278+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*source*'
279+
280+
$script:state.GitHubCreationUpload | Should -Be 0
281+
$script:state.Release | Should -BeNullOrEmpty
282+
}
258283
}

0 commit comments

Comments
 (0)