Skip to content

Commit 86c4f1b

Browse files
Restrict prerelease cleanup to the exact published branch target
1 parent ddd57bd commit 86c4f1b

4 files changed

Lines changed: 208 additions & 25 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Cleanup-PSModulePrereleases
2+
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+
Substring matches, sanitized-name collisions, numeric suffixes, stable releases, and another branch's records do not
6+
establish cleanup ownership.
7+
8+
The normalized `PullRequest` JSON and event-payload fallback retain their existing interfaces. `ReleaseTag` is always
9+
excluded, and `WhatIf: true` reports the exact commands without deleting anything. Releases targeted at an immutable
10+
SHA rather than the exact branch are not eligible through this branch-based interface. Malformed release records or a
11+
failed GitHub lookup stop cleanup instead of guessing.
12+
13+
The action fetches every release page through the GitHub API. Tests replace the GitHub CLI with a local function, so
14+
ownership, retained-tag, collision, and dry-run checks do not create or delete real releases.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
$ErrorActionPreference = 'Stop'
2+
3+
function Get-PSModulePrereleaseForCleanup {
4+
<#
5+
.SYNOPSIS
6+
Select prereleases published from the exact pull request branch.
7+
8+
.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+
13+
.EXAMPLE
14+
Get-PSModulePrereleaseForCleanup -Release $releases -HeadRef 'feature/a'
15+
16+
Returns only prereleases whose targetCommitish is exactly feature/a.
17+
18+
.INPUTS
19+
None
20+
21+
You can't pipe objects to Get-PSModulePrereleaseForCleanup.
22+
23+
.OUTPUTS
24+
System.Management.Automation.PSCustomObject
25+
26+
A release eligible for branch-scoped cleanup.
27+
#>
28+
[CmdletBinding()]
29+
[OutputType([PSCustomObject])]
30+
param(
31+
# The unsanitized branch used as the prerelease publication target.
32+
[Parameter(Mandatory)]
33+
[ValidateNotNullOrEmpty()]
34+
[string] $HeadRef,
35+
36+
# Release objects with tagName, isPrerelease, and targetCommitish.
37+
[Parameter()]
38+
[object[]] $Release,
39+
40+
# The tag created by the current publication, which must be retained.
41+
[Parameter()]
42+
[string] $ReleaseTag
43+
)
44+
45+
foreach ($candidate in $Release) {
46+
if ($null -eq $candidate -or -not $candidate.isPrerelease -or $candidate.tagName -ceq $ReleaseTag) {
47+
continue
48+
}
49+
if ([string]::IsNullOrWhiteSpace($candidate.tagName) -or
50+
[string]::IsNullOrWhiteSpace($candidate.targetCommitish)) {
51+
throw 'Cannot determine prerelease cleanup ownership: a release is missing its tag or targetCommitish.'
52+
}
53+
if ($candidate.targetCommitish -ceq $HeadRef) {
54+
$candidate
55+
}
56+
}
57+
}
Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
[CmdletBinding()]
1+
[CmdletBinding()]
22
param()
33

4+
$ErrorActionPreference = 'Stop'
45
$PSStyle.OutputRendering = 'Ansi'
56

67
Import-Module -Name 'PSModule' -Force
8+
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Cleanup-PSModulePrereleases.Helpers.psm1') -Force
79

810
#region Load inputs
9-
LogGroup 'Load inputs' {
11+
Set-GitHubLogGroup 'Load inputs' {
1012
$whatIf = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf -eq 'true'
1113

1214
$pullRequestJson = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest
@@ -22,15 +24,7 @@ LogGroup 'Load inputs' {
2224
Write-Host '::notice::No pull request head ref is available. Nothing to cleanup.'
2325
exit 0
2426
}
25-
$prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]'
26-
27-
if ([string]::IsNullOrWhiteSpace($prereleaseName)) {
28-
Write-Host "No prerelease tag derivable from PR head ref [$prHeadRef]. Nothing to cleanup."
29-
exit 0
30-
}
31-
3227
Write-Host "PR head ref: [$prHeadRef]"
33-
Write-Host "Prerelease name: [$prereleaseName]"
3428
Write-Host "WhatIf: [$whatIf]"
3529

3630
$publishedReleaseTag = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag
@@ -41,21 +35,23 @@ LogGroup 'Load inputs' {
4135
#endregion Load inputs
4236

4337
#region Find prereleases to cleanup
44-
LogGroup "Find prereleases to cleanup for [$prereleaseName]" {
45-
$releaseListOutput = gh release list --json 'createdAt,isDraft,isLatest,isPrerelease,name,publishedAt,tagName'
38+
Set-GitHubLogGroup "Find prereleases to cleanup for [$prHeadRef]" {
39+
$releaseListOutput = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" --paginate `
40+
--jq '.[] | {tagName: .tag_name, isPrerelease: .prerelease, targetCommitish: .target_commitish}'
4641
if ($LASTEXITCODE -ne 0) {
47-
Write-Error 'Failed to list releases for the repository.'
48-
exit $LASTEXITCODE
42+
throw "Failed to list releases for the repository (gh exited $LASTEXITCODE)."
4943
}
50-
$releases = $releaseListOutput | ConvertFrom-Json
51-
52-
$prereleasesToCleanup = $releases | Where-Object {
53-
$_.isPrerelease -eq $true -and $_.tagName -like "*$prereleaseName*" -and $_.tagName -ne $publishedReleaseTag
44+
$releases = @($releaseListOutput | ForEach-Object { $_ | ConvertFrom-Json })
45+
$parameters = @{
46+
HeadRef = $prHeadRef
47+
Release = $releases
48+
ReleaseTag = $publishedReleaseTag
5449
}
50+
$prereleasesToCleanup = Get-PSModulePrereleaseForCleanup @parameters
5551
$tagsToDelete = @($prereleasesToCleanup | ForEach-Object { $_.tagName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
5652

5753
if ($tagsToDelete.Count -eq 0) {
58-
Write-Host "No prereleases found to cleanup for [$prereleaseName]."
54+
Write-Host "No prereleases have an exact cleanup target of [$prHeadRef]."
5955
return
6056
}
6157

@@ -65,21 +61,20 @@ LogGroup "Find prereleases to cleanup for [$prereleaseName]" {
6561
#endregion Find prereleases to cleanup
6662

6763
#region Delete prereleases
68-
LogGroup "Delete prereleases for [$prereleaseName]" {
64+
Set-GitHubLogGroup "Delete prereleases for [$prHeadRef]" {
6965
foreach ($tag in $tagsToDelete) {
7066
Write-Host "Deleting prerelease: [$tag]"
7167
if ($whatIf) {
72-
Write-Host "WhatIf: gh release delete $tag --cleanup-tag --yes"
68+
Write-Host "WhatIf: gh release delete --cleanup-tag --yes -- $tag"
7369
} else {
74-
gh release delete $tag --cleanup-tag --yes
70+
gh release delete --cleanup-tag --yes -- $tag
7571
if ($LASTEXITCODE -ne 0) {
76-
Write-Error "Failed to delete release [$tag]."
77-
exit $LASTEXITCODE
72+
throw "Failed to delete release [$tag] (gh exited $LASTEXITCODE)."
7873
}
7974
Write-Host "Successfully deleted release [$tag]."
8075
}
8176
}
8277

83-
Write-Host "::notice::Cleaned up $($tagsToDelete.Count) prerelease(s) for [$prereleaseName]."
78+
Write-Host "::notice::Cleaned up $($tagsToDelete.Count) prerelease(s) for [$prHeadRef]."
8479
}
8580
#endregion Delete prereleases
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
2+
'PSUseDeclaredVarsMoreThanAssignments', '',
3+
Justification = 'Variables are assigned in BeforeAll and used inside It blocks.'
4+
)]
5+
[CmdletBinding()]
6+
param()
7+
8+
BeforeAll {
9+
Import-Module -Name PSModule -Force
10+
$script:cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/cleanup.ps1'
11+
$script:environmentNames = @(
12+
'GITHUB_REPOSITORY'
13+
'GITHUB_EVENT_PATH'
14+
'PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest'
15+
'PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf'
16+
'PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag'
17+
)
18+
$script:originalEnvironment = @{}
19+
foreach ($name in $script:environmentNames) {
20+
$script:originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
21+
}
22+
}
23+
24+
AfterAll {
25+
foreach ($name in $script:environmentNames) {
26+
[Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
27+
}
28+
Remove-Item -Path 'Function:\gh' -ErrorAction SilentlyContinue
29+
}
30+
31+
Describe 'Cleanup-PSModulePrereleases ownership' {
32+
BeforeEach {
33+
$script:state = @{
34+
Calls = [System.Collections.Generic.List[string]]::new()
35+
Releases = @(
36+
[pscustomobject]@{
37+
tagName = 'v1.2.4-featurea001'; isPrerelease = $true; targetCommitish = 'feature/a'
38+
}
39+
[pscustomobject]@{
40+
tagName = 'v1.2.4-featureab001'; isPrerelease = $true; targetCommitish = 'feature/ab'
41+
}
42+
[pscustomobject]@{
43+
tagName = 'v1.2.4-featurea002'; isPrerelease = $true; targetCommitish = 'feature-a'
44+
}
45+
[pscustomobject]@{
46+
tagName = 'v1.2.4-featurea003'; isPrerelease = $true; targetCommitish = 'feature/a'
47+
}
48+
[pscustomobject]@{
49+
tagName = 'v1.2.4'; isPrerelease = $false; targetCommitish = 'feature/a'
50+
}
51+
)
52+
}
53+
$state = $script:state
54+
Set-Item -Path 'Function:global:gh' -Value {
55+
[CmdletBinding()]
56+
param(
57+
[Parameter(ValueFromRemainingArguments)]
58+
[string[]] $Arguments
59+
)
60+
61+
$state.Calls.Add($Arguments -join ' ')
62+
$global:LASTEXITCODE = 0
63+
if ($Arguments[0] -eq 'api' -or ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'list')) {
64+
$state.Releases | ConvertTo-Json -Compress
65+
}
66+
}.GetNewClosure()
67+
$eventPath = Join-Path -Path $TestDrive -ChildPath 'dispatch.json'
68+
@{} | ConvertTo-Json | Set-Content -LiteralPath $eventPath
69+
$env:GITHUB_EVENT_PATH = $eventPath
70+
$env:GITHUB_REPOSITORY = 'PSModule/CleanupFixture'
71+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
72+
Number = 42; HeadRef = 'feature/a'
73+
} | ConvertTo-Json
74+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf = 'true'
75+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag = 'v1.2.4-featurea003'
76+
}
77+
78+
It 'does not preview deleting another branch or a sanitized-name collision during a dispatch' {
79+
$output = & $script:cleanupScriptPath 6>&1 | Out-String
80+
81+
$output | Should -Match 'release delete.*v1\.2\.4-featurea001'
82+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featureab001'
83+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featurea002'
84+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featurea003'
85+
@($script:state.Calls | Where-Object { $_ -like 'release delete*' }).Count | Should -Be 0
86+
}
87+
88+
It 'deletes only exact branch-owned prereleases and retains the published tag' {
89+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf = 'false'
90+
91+
& $script:cleanupScriptPath
92+
93+
$deletions = @($script:state.Calls | Where-Object { $_ -like 'release delete*' })
94+
$deletions.Count | Should -Be 1
95+
$deletions[0] | Should -Match 'v1\.2\.4-featurea001'
96+
$deletions[0] | Should -Not -Match 'featureab|featurea002|featurea003'
97+
}
98+
99+
It 'keeps numeric branch identifiers distinct from prerelease counters' {
100+
$script:state.Releases = @(
101+
[pscustomobject]@{
102+
tagName = 'v1.2.4-feature1001'; isPrerelease = $true; targetCommitish = 'feature/1'
103+
}
104+
[pscustomobject]@{
105+
tagName = 'v1.2.4-feature10001'; isPrerelease = $true; targetCommitish = 'feature/10'
106+
}
107+
)
108+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
109+
Number = 42; HeadRef = 'feature/1'
110+
} | ConvertTo-Json
111+
112+
$output = & $script:cleanupScriptPath 6>&1 | Out-String
113+
114+
$output | Should -Match 'release delete.*v1\.2\.4-feature1001'
115+
$output | Should -Not -Match 'release delete.*v1\.2\.4-feature10001'
116+
}
117+
}

0 commit comments

Comments
 (0)