Skip to content

Commit 0a0119c

Browse files
Exclude ambiguous recovery refs and serialize environment-sensitive action tests
1 parent c4decfa commit 0a0119c

11 files changed

Lines changed: 299 additions & 33 deletions

File tree

‎.github/actions/Cleanup-PSModulePrereleases/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
Removes prerelease GitHub releases and their tags only when the release's `target_commitish` exactly matches the
44
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.
56
Substring matches, sanitized-name collisions, numeric suffixes, stable releases, and another branch's records do not
67
establish cleanup ownership.
78

@@ -10,5 +11,8 @@ excluded, and `WhatIf: true` reports the exact commands without deleting anythin
1011
SHA rather than the exact branch are not eligible through this branch-based interface. Malformed release records or a
1112
failed GitHub lookup stop cleanup instead of guessing.
1213

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.
16+
1317
The action fetches every release page through the GitHub API. Tests replace the GitHub CLI with a local function, so
1418
ownership, retained-tag, collision, and dry-run checks do not create or delete real releases.

‎.github/actions/Cleanup-PSModulePrereleases/src/Cleanup-PSModulePrereleases.Helpers.psm1‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ function Get-PSModulePrereleaseForCleanup {
1111
branch's releases, and the supplied published tag are never selected.
1212
1313
.EXAMPLE
14-
Get-PSModulePrereleaseForCleanup -Release $releases -HeadRef 'feature/a'
14+
Get-PSModulePrereleaseForCleanup -Release $releases -HeadRef 'feature/a' `
15+
-Repository 'PSModule/MyModule' -HeadRepository 'PSModule/MyModule'
1516
16-
Returns only prereleases whose targetCommitish is exactly feature/a.
17+
Returns only same-repository prereleases whose targetCommitish is exactly feature/a.
1718
1819
.INPUTS
1920
None
@@ -33,6 +34,16 @@ function Get-PSModulePrereleaseForCleanup {
3334
[ValidateNotNullOrEmpty()]
3435
[string] $HeadRef,
3536

37+
# The base repository in owner/name form.
38+
[Parameter(Mandatory)]
39+
[ValidateNotNullOrEmpty()]
40+
[string] $Repository,
41+
42+
# The repository that owns the unsanitized head reference.
43+
[Parameter(Mandatory)]
44+
[ValidateNotNullOrEmpty()]
45+
[string] $HeadRepository,
46+
3647
# Release objects with tagName, isPrerelease, and targetCommitish.
3748
[Parameter()]
3849
[object[]] $Release,
@@ -42,6 +53,11 @@ function Get-PSModulePrereleaseForCleanup {
4253
[string] $ReleaseTag
4354
)
4455

56+
if ($HeadRepository -ne $Repository) {
57+
Write-Verbose "Fork-owned ref [${HeadRepository}:$HeadRef] cannot establish base-repository cleanup ownership."
58+
return
59+
}
60+
4561
foreach ($candidate in $Release) {
4662
if ($null -eq $candidate -or -not $candidate.isPrerelease -or $candidate.tagName -ceq $ReleaseTag) {
4763
continue

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

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,41 @@ Set-GitHubLogGroup 'Load inputs' {
1212
$whatIf = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf -eq 'true'
1313

1414
$pullRequestJson = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest
15-
$prHeadRef = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') {
16-
($pullRequestJson | ConvertFrom-Json).HeadRef
15+
$pullRequest = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') {
16+
$pullRequestJson | ConvertFrom-Json
1717
} else {
1818
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
1919
$githubEvent = $githubEventJson | ConvertFrom-Json
20-
$githubEvent.pull_request.head.ref
20+
[pscustomobject]@{
21+
Number = $githubEvent.pull_request.number
22+
HeadRef = $githubEvent.pull_request.head.ref
23+
HeadRepository = $githubEvent.pull_request.head.repo.full_name
24+
}
2125
}
26+
$prHeadRef = $pullRequest.HeadRef
2227

2328
if ([string]::IsNullOrWhiteSpace($prHeadRef)) {
2429
Write-Host '::notice::No pull request head ref is available. Nothing to cleanup.'
2530
exit 0
2631
}
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
49+
}
2750
Write-Host "PR head ref: [$prHeadRef]"
2851
Write-Host "WhatIf: [$whatIf]"
2952

@@ -43,9 +66,11 @@ Set-GitHubLogGroup "Find prereleases to cleanup for [$prHeadRef]" {
4366
}
4467
$releases = @($releaseListOutput | ForEach-Object { $_ | ConvertFrom-Json })
4568
$parameters = @{
46-
HeadRef = $prHeadRef
47-
Release = $releases
48-
ReleaseTag = $publishedReleaseTag
69+
HeadRef = $prHeadRef
70+
Repository = $env:GITHUB_REPOSITORY
71+
HeadRepository = $headRepository
72+
Release = $releases
73+
ReleaseTag = $publishedReleaseTag
4974
}
5075
$prereleasesToCleanup = Get-PSModulePrereleaseForCleanup @parameters
5176
$tagsToDelete = @($prereleasesToCleanup | ForEach-Object { $_.tagName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
param()
77

88
BeforeAll {
9+
$script:environmentMutex = [Threading.Mutex]::new($false, "PSModule-ActionTests-Environment-$PID")
10+
if (-not $script:environmentMutex.WaitOne([TimeSpan]::FromMinutes(5))) {
11+
throw 'Cannot acquire the process environment lock for action tests.'
12+
}
913
Import-Module -Name PSModule -Force
1014
$script:cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/cleanup.ps1'
1115
$script:environmentNames = @(
@@ -22,10 +26,15 @@ BeforeAll {
2226
}
2327

2428
AfterAll {
25-
foreach ($name in $script:environmentNames) {
26-
[Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
29+
try {
30+
foreach ($name in $script:environmentNames) {
31+
[Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
32+
}
33+
Remove-Item -Path 'Function:\gh' -ErrorAction SilentlyContinue
34+
} finally {
35+
$script:environmentMutex.ReleaseMutex()
36+
$script:environmentMutex.Dispose()
2737
}
28-
Remove-Item -Path 'Function:\gh' -ErrorAction SilentlyContinue
2938
}
3039

3140
Describe 'Cleanup-PSModulePrereleases ownership' {
@@ -60,7 +69,9 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
6069

6170
$state.Calls.Add($Arguments -join ' ')
6271
$global:LASTEXITCODE = 0
63-
if ($Arguments[0] -eq 'api' -or ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'list')) {
72+
if ($Arguments[0] -eq 'api' -and $Arguments[1] -match '/pulls/') {
73+
'PSModule/CleanupFixture'
74+
} elseif ($Arguments[0] -eq 'api' -or ($Arguments[0] -eq 'release' -and $Arguments[1] -eq 'list')) {
6475
$state.Releases | ConvertTo-Json -Compress
6576
}
6677
}.GetNewClosure()
@@ -70,6 +81,7 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
7081
$env:GITHUB_REPOSITORY = 'PSModule/CleanupFixture'
7182
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
7283
Number = 42; HeadRef = 'feature/a'
84+
HeadRepository = 'PSModule/CleanupFixture'
7385
} | ConvertTo-Json
7486
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf = 'true'
7587
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag = 'v1.2.4-featurea003'
@@ -107,11 +119,38 @@ Describe 'Cleanup-PSModulePrereleases ownership' {
107119
)
108120
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
109121
Number = 42; HeadRef = 'feature/1'
122+
HeadRepository = 'PSModule/CleanupFixture'
110123
} | ConvertTo-Json
111124

112125
$output = & $script:cleanupScriptPath 6>&1 | Out-String
113126

114127
$output | Should -Match 'release delete.*v1\.2\.4-feature1001'
115128
$output | Should -Not -Match 'release delete.*v1\.2\.4-feature10001'
116129
}
130+
131+
It 'does not clean base-repository prereleases for a fork with the same head ref' {
132+
$script:state.Releases = @([pscustomobject]@{
133+
tagName = 'v1.2.4-main001'; isPrerelease = $true; targetCommitish = 'main'
134+
})
135+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
136+
Number = 42; HeadRef = 'main'; HeadRepository = 'AnotherOwner/Fork'
137+
} | ConvertTo-Json
138+
139+
$output = & $script:cleanupScriptPath 6>&1 | Out-String
140+
141+
$output | Should -Not -Match 'release delete.*v1\.2\.4-main001'
142+
@($script:state.Calls | Where-Object { $_ -like 'release delete*' }).Count | Should -Be 0
143+
}
144+
145+
It 'resolves repository ownership for an older normalized PR payload' {
146+
$env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest = @{
147+
Number = 42; HeadRef = 'feature/a'
148+
} | ConvertTo-Json
149+
150+
$output = & $script:cleanupScriptPath 6>&1 | Out-String
151+
152+
$output | Should -Match 'release delete.*v1\.2\.4-featurea001'
153+
@($script:state.Calls | Where-Object { $_ -match '/pulls/42' }).Count | Should -Be 1
154+
$output | Should -Not -Match 'release delete.*v1\.2\.4-featureab001'
155+
}
117156
}

‎.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
Import-Module -Name (
2+
Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1'
3+
) -Force
4+
15
function Resolve-WorkflowEventRouting {
26
<#
37
.SYNOPSIS
@@ -227,7 +231,11 @@ function Resolve-ReleasePullRequest {
227231

228232
# Returns the pull requests GitHub associates with a commit. Takes the commit SHA.
229233
[Parameter(Mandatory)]
230-
[scriptblock] $GetAssociatedPullRequest
234+
[scriptblock] $GetAssociatedPullRequest,
235+
236+
# Returns published GitHub releases whose immutable tag points to the commit.
237+
[Parameter()]
238+
[scriptblock] $GetPublishedReleaseForCommit
231239
)
232240

233241
$isPush = $EventName -eq 'push'
@@ -245,6 +253,17 @@ function Resolve-ReleasePullRequest {
245253
-DefaultBranch $DefaultBranch `
246254
-CommitSha $CommitSha
247255

256+
if ($IsManualDispatchToDefaultBranch -and $null -ne $GetPublishedReleaseForCommit) {
257+
$published = @((& $GetPublishedReleaseForCommit $CommitSha) | Where-Object { $null -ne $_ })
258+
if ($published.Count -gt 0) {
259+
throw (
260+
"Commit [$CommitSha] already has a GitHub release: [$($published.tag_name -join ', ')]. " +
261+
'Do not resolve another version bump for this dispatch. Verify that the original version/tag ' +
262+
'is unclaimed or belongs to its original commit, then rerun the original failed publication job.'
263+
)
264+
}
265+
}
266+
248267
if ($pullRequest) {
249268
Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$CommitSha]."
250269
return [pscustomobject]@{ Resolved = $true; PullRequest = $pullRequest }
@@ -266,7 +285,8 @@ function Resolve-ReleasePullRequest {
266285
'The following merged pull request(s) are associated with it but none matches the commit ' +
267286
"being released: $($discarded -join '; '). " +
268287
'Refusing to fall back to a patch bump, because a wrong version published to the ' +
269-
'PowerShell Gallery cannot be reclaimed. Rerun the original workflow for the intended merge commit. ' +
288+
'PowerShell Gallery cannot be reclaimed. Verify that the original version/tag is unclaimed or belongs to ' +
289+
'the original commit before rerunning its failed workflow job. ' +
270290
'A default-branch dispatch can recover that commit only while it is still the branch tip.'
271291
)
272292
}
@@ -276,6 +296,72 @@ function Resolve-ReleasePullRequest {
276296
}
277297
}
278298

299+
function Get-PSModuleReleaseForCommit {
300+
<#
301+
.SYNOPSIS
302+
Find published releases bound to an exact commit.
303+
304+
.DESCRIPTION
305+
Resolves each stable release's tag reference and peels annotated tag
306+
objects. A mutable target_commitish creation hint is not source proof.
307+
Unresolvable or cyclic tag data fails rather than inventing a new bump.
308+
309+
.EXAMPLE
310+
Get-PSModuleReleaseForCommit -CommitSha $sha -Release $releases `
311+
-GetTagReference $readRef -GetTagObject $readTag
312+
313+
Returns stable releases whose actual tag resolves to the supplied SHA.
314+
315+
.INPUTS
316+
None
317+
318+
You can't pipe objects to Get-PSModuleReleaseForCommit.
319+
320+
.OUTPUTS
321+
System.Management.Automation.PSCustomObject
322+
323+
A published release bound to the supplied commit.
324+
#>
325+
[CmdletBinding()]
326+
[OutputType([PSCustomObject])]
327+
param(
328+
# The immutable source commit being considered for recovery.
329+
[Parameter(Mandatory)]
330+
[ValidateNotNullOrEmpty()]
331+
[string] $CommitSha,
332+
333+
# Returns the object referenced by a release tag name.
334+
[Parameter(Mandatory)]
335+
[scriptblock] $GetTagReference,
336+
337+
# Returns the object targeted by an annotated-tag SHA.
338+
[Parameter(Mandatory)]
339+
[scriptblock] $GetTagObject,
340+
341+
# REST release records, including tag_name, prerelease, and draft.
342+
[Parameter()]
343+
[object[]] $Release
344+
)
345+
346+
foreach ($candidate in $Release) {
347+
if ($null -eq $candidate -or $candidate.prerelease -or $candidate.draft) {
348+
continue
349+
}
350+
if ([string]::IsNullOrWhiteSpace($candidate.tag_name)) {
351+
throw 'Cannot determine release source: a release has no tag_name.'
352+
}
353+
$parameters = @{
354+
Tag = $candidate.tag_name
355+
GetTagReference = $GetTagReference
356+
GetTagObject = $GetTagObject
357+
}
358+
$source = Resolve-PSModuleReleaseTagCommit @parameters
359+
if ($source -ceq $CommitSha) {
360+
$candidate
361+
}
362+
}
363+
}
364+
279365
function Get-FilesFromGitTree {
280366
<#
281367
.SYNOPSIS

‎.github/actions/Get-PSModuleSettings/src/main.ps1‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,9 @@ Set-GitHubLogGroup 'Calculate Job Run Conditions:' {
243243
$commitSha = if ($isPush) { $eventData.After ?? $env:GITHUB_SHA } else { $env:GITHUB_SHA }
244244
$pushBranch = if ($isPush) { $eventData.Ref -replace '^refs/heads/', '' } else { '' }
245245
$workflowRef = if ($isPush) { $pushBranch } else { $env:GITHUB_REF_NAME }
246-
$isPushToDefaultBranch = $isPush -and $pushBranch -eq $defaultBranch
247-
$isManualDispatchToDefaultBranch = $isManualDispatch -and $workflowRef -eq $defaultBranch
246+
$defaultBranchRef = "refs/heads/$defaultBranch"
247+
$isPushToDefaultBranch = $isPush -and $eventData.Ref -ceq $defaultBranchRef
248+
$isManualDispatchToDefaultBranch = $isManualDispatch -and $env:GITHUB_REF -ceq $defaultBranchRef
248249
$pullRequest = $eventData.PullRequest
249250

250251
$pullRequestIsClosed = $null -ne $pullRequest -and $pullRequest.State -eq 'closed'
@@ -386,6 +387,26 @@ If you believe this is incorrect, please verify that your changes are in the cor
386387
$response = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/commits/$Sha/pulls" -Method GET
387388
$response.Response
388389
}
390+
GetPublishedReleaseForCommit = {
391+
param($Sha)
392+
$owner = $env:GITHUB_REPOSITORY_OWNER
393+
$repo = $env:GITHUB_REPOSITORY_NAME
394+
$releases = (Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/releases?per_page=100" -Method GET -ErrorAction Stop).Response
395+
$parameters = @{
396+
CommitSha = $Sha
397+
Release = @($releases)
398+
GetTagReference = {
399+
param($Tag)
400+
$encodedTag = [Uri]::EscapeDataString($Tag)
401+
(Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/ref/tags/$encodedTag" -Method GET -ErrorAction Stop).Response.object
402+
}.GetNewClosure()
403+
GetTagObject = {
404+
param($TagSha)
405+
(Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/tags/$TagSha" -Method GET -ErrorAction Stop).Response.object
406+
}.GetNewClosure()
407+
}
408+
Get-PSModuleReleaseForCommit @parameters
409+
}
389410
}
390411
$resolution = Resolve-ReleasePullRequest @resolveParams
391412
if ($resolution.Resolved) {
@@ -408,6 +429,7 @@ If you believe this is incorrect, please verify that your changes are in the cor
408429
Title = $pullRequest.Title
409430
Body = $pullRequest.Body
410431
HeadRef = $pullRequest.Head.Ref
432+
HeadRepository = $pullRequest.Head.Repo.full_name
411433
BaseRef = $pullRequest.Base.Ref
412434
Labels = @($pullRequest.Labels.Name)
413435
Merged = $pullRequestIsMerged

0 commit comments

Comments
 (0)