Skip to content

Commit d06a877

Browse files
Bind prerelease retries and cleanup to immutable PR ownership
1 parent 2c77a09 commit d06a877

10 files changed

Lines changed: 436 additions & 94 deletions

File tree

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
# Cleanup-PSModulePrereleases
22

3-
Removes prerelease GitHub releases and their tags only when the release's `target_commitish` exactly matches the
4-
unsanitized PR head branch supplied by the workflow. `Release-PSModule` records that branch as its prerelease target.
5-
The head repository must also match the base repository; a fork's identically named branch is not ownership proof.
3+
Removes prerelease GitHub releases and their tags only when cleanup ownership is established. Source-bound
4+
prereleases use the final controller-owned footer written by `Release-PSModule`: the exact base repository and PR
5+
number must match, and the actual tag must resolve to its recorded immutable commit.
6+
7+
Legacy releases without that footer are retained. A branch or `target_commitish` hint cannot prove which PR or
8+
repository produced a record, even when its short name matches the current PR.
69
Substring matches, sanitized-name collisions, numeric suffixes, stable releases, and another branch's records do not
710
establish cleanup ownership.
811

912
The normalized `PullRequest` JSON and event-payload fallback retain their existing interfaces. `ReleaseTag` is always
10-
excluded, and `WhatIf: true` reports the exact commands without deleting anything. Releases targeted at an immutable
11-
SHA rather than the exact branch are not eligible through this branch-based interface. Malformed release records or a
12-
failed GitHub lookup stop cleanup instead of guessing.
13+
excluded, and `WhatIf: true` reports commands without deleting anything. SHA-targeted records without verified
14+
controller provenance are retained. Malformed records, conflicting tag/source provenance, or a failed GitHub lookup
15+
stop cleanup instead of guessing.
1316

14-
Normalized context includes `HeadRepository`. For an older payload, the action reads the PR's repository from its
15-
number before selecting releases. Missing or unverifiable repository ownership fails explicitly.
17+
The existing normalized `PullRequest` context supplies the source PR number. Missing or invalid PR identity fails
18+
explicitly; older unproven release records are not rewritten or deleted by adopting this version.
1619

1720
The action fetches every release page through the GitHub API. Tests replace the GitHub CLI with a local function, so
1821
ownership, retained-tag, collision, and dry-run checks do not create or delete real releases.
Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
$ErrorActionPreference = 'Stop'
2+
Import-Module -Name (
3+
Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1'
4+
) -Force
25

36
function Get-PSModulePrereleaseForCleanup {
47
<#
58
.SYNOPSIS
6-
Select prereleases published from the exact pull request branch.
9+
Select prereleases with verified producing PR and source ownership.
710
811
.DESCRIPTION
9-
Uses the GitHub release target retained by Release-PSModule instead of a
10-
lossy branch-name substring in the version tag. Stable releases, another
11-
branch's releases, and the supplied published tag are never selected.
12+
Uses controller-owned repository, PR, and commit provenance rather than
13+
branch or tag substrings. Stable and unverified legacy releases, another
14+
PR's releases, and the supplied published tag are never selected.
1215
1316
.EXAMPLE
14-
Get-PSModulePrereleaseForCleanup -Release $releases -HeadRef 'feature/a' `
15-
-Repository 'PSModule/MyModule' -HeadRepository 'PSModule/MyModule'
17+
Get-PSModulePrereleaseForCleanup -Release $releases -Repository 'PSModule/MyModule' `
18+
-PullRequestNumber 42 -GetTagCommit $readTagCommit
1619
17-
Returns only same-repository prereleases whose targetCommitish is exactly feature/a.
20+
Returns only verified prereleases produced for MyModule PR 42.
1821
1922
.INPUTS
2023
None
@@ -29,20 +32,18 @@ function Get-PSModulePrereleaseForCleanup {
2932
[CmdletBinding()]
3033
[OutputType([PSCustomObject])]
3134
param(
32-
# The unsanitized branch used as the prerelease publication target.
33-
[Parameter(Mandatory)]
34-
[ValidateNotNullOrEmpty()]
35-
[string] $HeadRef,
36-
3735
# The base repository in owner/name form.
3836
[Parameter(Mandatory)]
3937
[ValidateNotNullOrEmpty()]
4038
[string] $Repository,
4139

42-
# The repository that owns the unsanitized head reference.
43-
[Parameter(Mandatory)]
44-
[ValidateNotNullOrEmpty()]
45-
[string] $HeadRepository,
40+
# The source PR number within the base repository.
41+
[Parameter()]
42+
[long] $PullRequestNumber,
43+
44+
# Returns the exact immutable commit referenced by a release tag.
45+
[Parameter()]
46+
[scriptblock] $GetTagCommit,
4647

4748
# Release objects with tagName, isPrerelease, and targetCommitish.
4849
[Parameter()]
@@ -53,11 +54,6 @@ function Get-PSModulePrereleaseForCleanup {
5354
[string] $ReleaseTag
5455
)
5556

56-
if ($HeadRepository -ne $Repository) {
57-
Write-Verbose "Fork-owned ref [${HeadRepository}:$HeadRef] cannot establish base-repository cleanup ownership."
58-
return
59-
}
60-
6157
foreach ($candidate in $Release) {
6258
if ($null -eq $candidate -or -not $candidate.isPrerelease -or $candidate.tagName -ceq $ReleaseTag) {
6359
continue
@@ -66,8 +62,25 @@ function Get-PSModulePrereleaseForCleanup {
6662
[string]::IsNullOrWhiteSpace($candidate.targetCommitish)) {
6763
throw 'Cannot determine prerelease cleanup ownership: a release is missing its tag or targetCommitish.'
6864
}
69-
if ($candidate.targetCommitish -ceq $HeadRef) {
65+
$ownership = Get-PSModulePrereleaseOwnership -Body ([string]$candidate.body)
66+
if ($null -ne $ownership) {
67+
if ($ownership.Repository -ne $Repository -or $ownership.PullRequest -ne $PullRequestNumber) {
68+
continue
69+
}
70+
if ($candidate.targetCommitish -cne $ownership.CommitSha) {
71+
Write-Verbose "Retaining [$($candidate.tagName)] because its creation target does not bind the ownership source."
72+
continue
73+
}
74+
if ($null -eq $GetTagCommit) {
75+
throw 'Immutable tag lookup is required to verify published prerelease ownership.'
76+
}
77+
$tagCommit = & $GetTagCommit $candidate.tagName
78+
if ($tagCommit -cne $ownership.CommitSha) {
79+
throw "Prerelease tag [$($candidate.tagName)] no longer matches its recorded source. Refusing cleanup."
80+
}
7081
$candidate
82+
} else {
83+
Write-Verbose "Retaining legacy release [$($candidate.tagName)] without producing PR/source ownership evidence."
7184
}
7285
}
7386
}

‎.github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1‎

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,8 @@ Set-GitHubLogGroup 'Load inputs' {
2929
Write-Host '::notice::No pull request head ref is available. Nothing to cleanup.'
3030
exit 0
3131
}
32-
$headRepository = $pullRequest.HeadRepository
33-
if ([string]::IsNullOrWhiteSpace($headRepository) -and $pullRequest.Number) {
34-
$number = [int]$pullRequest.Number
35-
if ($number -lt 1) {
36-
throw 'A positive pull request number is required to establish cleanup ownership.'
37-
}
38-
$headRepository = gh api "repos/$env:GITHUB_REPOSITORY/pulls/$number" --jq '.head.repo.full_name'
39-
if ($LASTEXITCODE -ne 0) {
40-
throw "Cannot establish cleanup head repository (gh exited $LASTEXITCODE)."
41-
}
42-
}
43-
if ([string]::IsNullOrWhiteSpace($headRepository)) {
44-
throw 'Cannot establish the head repository for prerelease cleanup. Supply normalized PR context.'
45-
}
46-
if ($headRepository -ne $env:GITHUB_REPOSITORY) {
47-
Write-Host "::notice::Fork-owned branch [$headRepository/$prHeadRef] is not eligible for base-repository cleanup."
48-
return
32+
if ([long]$pullRequest.Number -lt 1) {
33+
throw 'A positive source PR number is required for verified prerelease cleanup.'
4934
}
5035
Write-Host "PR head ref: [$prHeadRef]"
5136
Write-Host "WhatIf: [$whatIf]"
@@ -60,23 +45,46 @@ Set-GitHubLogGroup 'Load inputs' {
6045
#region Find prereleases to cleanup
6146
Set-GitHubLogGroup "Find prereleases to cleanup for [$prHeadRef]" {
6247
$releaseListOutput = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" --paginate `
63-
--jq '.[] | {tagName: .tag_name, isPrerelease: .prerelease, targetCommitish: .target_commitish}'
48+
--jq '.[] | {tagName: .tag_name, isPrerelease: .prerelease, targetCommitish: .target_commitish, body: .body}'
6449
if ($LASTEXITCODE -ne 0) {
6550
throw "Failed to list releases for the repository (gh exited $LASTEXITCODE)."
6651
}
6752
$releases = @($releaseListOutput | ForEach-Object { $_ | ConvertFrom-Json })
6853
$parameters = @{
69-
HeadRef = $prHeadRef
70-
Repository = $env:GITHUB_REPOSITORY
71-
HeadRepository = $headRepository
72-
Release = $releases
73-
ReleaseTag = $publishedReleaseTag
54+
Repository = $env:GITHUB_REPOSITORY
55+
PullRequestNumber = [long]$pullRequest.Number
56+
Release = $releases
57+
ReleaseTag = $publishedReleaseTag
58+
GetTagCommit = {
59+
param($Tag)
60+
$tagParameters = @{
61+
Tag = $Tag
62+
GetTagReference = {
63+
param($TagName)
64+
$encodedTag = [Uri]::EscapeDataString($TagName)
65+
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$encodedTag"
66+
if ($LASTEXITCODE -ne 0) {
67+
throw "Cannot verify cleanup tag [$TagName] (gh exited $LASTEXITCODE)."
68+
}
69+
($response | ConvertFrom-Json -ErrorAction Stop).object
70+
}
71+
GetTagObject = {
72+
param($TagSha)
73+
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/tags/$TagSha"
74+
if ($LASTEXITCODE -ne 0) {
75+
throw "Cannot verify cleanup tag object [$TagSha] (gh exited $LASTEXITCODE)."
76+
}
77+
($response | ConvertFrom-Json -ErrorAction Stop).object
78+
}
79+
}
80+
Resolve-PSModuleReleaseTagCommit @tagParameters
81+
}
7482
}
7583
$prereleasesToCleanup = Get-PSModulePrereleaseForCleanup @parameters
7684
$tagsToDelete = @($prereleasesToCleanup | ForEach-Object { $_.tagName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
7785

7886
if ($tagsToDelete.Count -eq 0) {
79-
Write-Host "No prereleases have an exact cleanup target of [$prHeadRef]."
87+
Write-Host "No prereleases have verified ownership for [$env:GITHUB_REPOSITORY#$($pullRequest.Number)]."
8088
return
8189
}
8290

‎.github/actions/Cleanup-PSModulePrereleases/tests/Cleanup-PSModulePrereleases.Tests.ps1‎

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ AfterAll {
4040
Describe 'Cleanup-PSModulePrereleases ownership' {
4141
BeforeEach {
4242
$script:state = @{
43-
Calls = [System.Collections.Generic.List[string]]::new()
44-
Releases = @(
43+
Calls = [System.Collections.Generic.List[string]]::new()
44+
TagSource = 'original-source-sha'
45+
Releases = @(
4546
[pscustomobject]@{
4647
tagName = 'v1.2.4-featurea001'; isPrerelease = $true; targetCommitish = 'feature/a'
4748
}
@@ -59,6 +60,14 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
5960
}
6061
)
6162
}
63+
foreach ($release in $script:state.Releases) {
64+
$number = if ($release.tagName -in @('v1.2.4-featureab001', 'v1.2.4-featurea002')) { 43 } else { 42 }
65+
$owner = @{ Repository = 'PSModule/CleanupFixture'; PullRequest = $number; CommitSha = 'original-source-sha' } |
66+
ConvertTo-Json -Compress
67+
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner))
68+
$release.targetCommitish = 'original-source-sha'
69+
$release | Add-Member -NotePropertyName body -NotePropertyValue "<!-- PSModule.PrereleaseOwner:v1:$encoded -->"
70+
}
6271
$state = $script:state
6372
Set-Item -Path 'Function:global:gh' -Value {
6473
[CmdletBinding()]
@@ -71,6 +80,8 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
7180
$global:LASTEXITCODE = 0
7281
if ($Arguments[0] -eq 'api' -and $Arguments[1] -match '/pulls/') {
7382
'PSModule/CleanupFixture'
83+
} elseif ($Arguments[0] -eq 'api' -and $Arguments[1] -match '/git/ref/tags/') {
84+
@{ object = @{ type = 'commit'; sha = $state.TagSource } } | ConvertTo-Json -Compress
7485
} elseif ($Arguments[0] -eq 'api' -or ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'list')) {
7586
$state.Releases | ConvertTo-Json -Compress
7687
}
@@ -117,6 +128,14 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
117128
tagName = 'v1.2.4-feature10001'; isPrerelease = $true; targetCommitish = 'feature/10'
118129
}
119130
)
131+
foreach ($release in $script:state.Releases) {
132+
$number = if ($release.tagName -eq 'v1.2.4-feature1001') { 42 } else { 43 }
133+
$owner = @{ Repository = 'PSModule/CleanupFixture'; PullRequest = $number; CommitSha = 'original-source-sha' } |
134+
ConvertTo-Json -Compress
135+
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner))
136+
$release.targetCommitish = 'original-source-sha'
137+
$release | Add-Member -NotePropertyName body -NotePropertyValue "<!-- PSModule.PrereleaseOwner:v1:$encoded -->"
138+
}
120139
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
121140
Number = 42; HeadRef = 'feature/1'
122141
HeadRepository = 'PSModule/CleanupFixture'
@@ -142,15 +161,63 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
142161
@($script:state.Calls | Where-Object { $_ -like 'release delete*' }).Count | Should -Be 0
143162
}
144163

145-
It 'resolves repository ownership for an older normalized PR payload' {
164+
It 'retains legacy releases whose producing PR cannot be established' {
165+
$script:state.Releases = @([pscustomobject]@{
166+
tagName = 'v1.2.4-featurea001'; isPrerelease = $true; targetCommitish = 'feature/a'
167+
})
146168
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
147169
Number = 42; HeadRef = 'feature/a'
148170
} | ConvertTo-Json
149171

150172
$output = & $script:cleanupScriptPath 6>&1 | Out-String
151173

152-
$output | Should -Match 'release delete.*v1\.2\.4-featurea001'
153-
@($script:state.Calls | Where-Object { $_ -match '/pulls/42' }).Count | Should -Be 1
174+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featurea001'
175+
@($script:state.Calls | Where-Object { $_ -like 'release delete*' }).Count | Should -Be 0
154176
$output | Should -Not -Match 'release delete.*v1\.2\.4-featureab001'
155177
}
178+
179+
It 'selects a SHA-targeted prerelease only from exact PR and source provenance for <HeadRepository>' -ForEach @(
180+
@{ HeadRepository = 'PSModule/CleanupFixture' }
181+
@{ HeadRepository = 'AnotherOwner/Fork' }
182+
) {
183+
$owner = @{ Repository = 'PSModule/CleanupFixture'; PullRequest = 42; CommitSha = 'original-source-sha' } |
184+
ConvertTo-Json -Compress
185+
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner))
186+
$foreign = @{ Repository = 'PSModule/CleanupFixture'; PullRequest = 43; CommitSha = 'original-source-sha' } |
187+
ConvertTo-Json -Compress
188+
$foreignEncoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($foreign))
189+
$script:state.Releases = @(
190+
[pscustomobject]@{
191+
tagName = 'v1.2.4-featurea001'; isPrerelease = $true; targetCommitish = 'original-source-sha'
192+
body = "Authored notes`n`n<!-- PSModule.PrereleaseOwner:v1:$encoded -->"
193+
}
194+
[pscustomobject]@{
195+
tagName = 'v1.2.4-featurea002'; isPrerelease = $true; targetCommitish = 'original-source-sha'
196+
body = "Authored notes`n`n<!-- PSModule.PrereleaseOwner:v1:$foreignEncoded -->"
197+
}
198+
)
199+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
200+
Number = 42; HeadRef = 'feature/a'; HeadRepository = $HeadRepository
201+
} | ConvertTo-Json
202+
203+
$output = & $script:cleanupScriptPath 6>&1 | Out-String
204+
205+
$output | Should -Match 'release delete.*v1\.2\.4-featurea001'
206+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featurea002'
207+
}
208+
209+
It 'refuses cleanup when the tag has moved away from its recorded immutable source' {
210+
$owner = @{ Repository = 'PSModule/CleanupFixture'; PullRequest = 42; CommitSha = 'original-source-sha' } |
211+
ConvertTo-Json -Compress
212+
$encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($owner))
213+
$script:state.Releases = @([pscustomobject]@{
214+
tagName = 'v1.2.4-featurea001'; isPrerelease = $true; targetCommitish = 'original-source-sha'
215+
body = "Notes`n`n<!-- PSModule.PrereleaseOwner:v1:$encoded -->"
216+
})
217+
$script:state.TagSource = 'another-source-sha'
218+
219+
{ & $script:cleanupScriptPath } | Should -Throw '*no longer matches*recorded source*'
220+
221+
@($script:state.Calls | Where-Object { $_ -like 'release delete*' }).Count | Should -Be 0
222+
}
156223
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ The action consumes a built module artifact and normalized PR or direct-release
4545
to its actual commit, including annotated tags; `target_commitish` is not treated as immutable proof. A retry proceeds
4646
only when that commit matches the original source.
4747

48+
Tag ownership is checked even when no GitHub release exists: a missing release is not evidence that the tag is
49+
unclaimed. The source is checked again immediately before asset upload to reject concurrent tag changes.
50+
51+
Prerelease creation and retry both use the original build commit, not a mutable PR head branch. The complete authored
52+
notes or generated notes are preserved, followed by a versioned controller-owned footer recording the base repository,
53+
source PR number, and immutable commit. This publication provenance lets cleanup retain unambiguous PR ownership
54+
without deriving it from a tag substring.
55+
4856
## Retrying publication
4957

5058
Use the original run's resolved tag, source, and artifact. A missing ZIP can be uploaded to the same-source release.

0 commit comments

Comments
 (0)