Skip to content

Commit 8e007ff

Browse files
Compose effective default bumps with complete source-safe recovery
2 parents ebf5be5 + 0a0119c commit 8e007ff

16 files changed

Lines changed: 604 additions & 76 deletions

‎.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
@@ -308,7 +312,11 @@ function Resolve-ReleasePullRequest {
308312

309313
# Returns the pull requests GitHub associates with a commit. Takes the commit SHA.
310314
[Parameter(Mandatory)]
311-
[scriptblock] $GetAssociatedPullRequest
315+
[scriptblock] $GetAssociatedPullRequest,
316+
317+
# Returns published GitHub releases whose immutable tag points to the commit.
318+
[Parameter()]
319+
[scriptblock] $GetPublishedReleaseForCommit
312320
)
313321

314322
$isPush = $EventName -eq 'push'
@@ -326,6 +334,17 @@ function Resolve-ReleasePullRequest {
326334
-DefaultBranch $DefaultBranch `
327335
-CommitSha $CommitSha
328336

337+
if ($IsManualDispatchToDefaultBranch -and $null -ne $GetPublishedReleaseForCommit) {
338+
$published = @((& $GetPublishedReleaseForCommit $CommitSha) | Where-Object { $null -ne $_ })
339+
if ($published.Count -gt 0) {
340+
throw (
341+
"Commit [$CommitSha] already has a GitHub release: [$($published.tag_name -join ', ')]. " +
342+
'Do not resolve another version bump for this dispatch. Verify that the original version/tag ' +
343+
'is unclaimed or belongs to its original commit, then rerun the original failed publication job.'
344+
)
345+
}
346+
}
347+
329348
if ($pullRequest) {
330349
Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$CommitSha]."
331350
return [pscustomobject]@{ Resolved = $true; PullRequest = $pullRequest }
@@ -347,7 +366,8 @@ function Resolve-ReleasePullRequest {
347366
'The following merged pull request(s) are associated with it but none matches the commit ' +
348367
"being released: $($discarded -join '; '). " +
349368
'Refusing to discard the release intent and apply DefaultBump, because a wrong version published to the ' +
350-
'PowerShell Gallery cannot be reclaimed. Rerun the original workflow for the intended merge commit. ' +
369+
'PowerShell Gallery cannot be reclaimed. Verify that the original version/tag is unclaimed or belongs to ' +
370+
'the original commit before rerunning its failed workflow job. ' +
351371
'A default-branch dispatch can recover that commit only while it is still the branch tip.'
352372
)
353373
}
@@ -357,6 +377,72 @@ function Resolve-ReleasePullRequest {
357377
}
358378
}
359379

380+
function Get-PSModuleReleaseForCommit {
381+
<#
382+
.SYNOPSIS
383+
Find published releases bound to an exact commit.
384+
385+
.DESCRIPTION
386+
Resolves each stable release's tag reference and peels annotated tag
387+
objects. A mutable target_commitish creation hint is not source proof.
388+
Unresolvable or cyclic tag data fails rather than inventing a new bump.
389+
390+
.EXAMPLE
391+
Get-PSModuleReleaseForCommit -CommitSha $sha -Release $releases `
392+
-GetTagReference $readRef -GetTagObject $readTag
393+
394+
Returns stable releases whose actual tag resolves to the supplied SHA.
395+
396+
.INPUTS
397+
None
398+
399+
You can't pipe objects to Get-PSModuleReleaseForCommit.
400+
401+
.OUTPUTS
402+
System.Management.Automation.PSCustomObject
403+
404+
A published release bound to the supplied commit.
405+
#>
406+
[CmdletBinding()]
407+
[OutputType([PSCustomObject])]
408+
param(
409+
# The immutable source commit being considered for recovery.
410+
[Parameter(Mandatory)]
411+
[ValidateNotNullOrEmpty()]
412+
[string] $CommitSha,
413+
414+
# Returns the object referenced by a release tag name.
415+
[Parameter(Mandatory)]
416+
[scriptblock] $GetTagReference,
417+
418+
# Returns the object targeted by an annotated-tag SHA.
419+
[Parameter(Mandatory)]
420+
[scriptblock] $GetTagObject,
421+
422+
# REST release records, including tag_name, prerelease, and draft.
423+
[Parameter()]
424+
[object[]] $Release
425+
)
426+
427+
foreach ($candidate in $Release) {
428+
if ($null -eq $candidate -or $candidate.prerelease -or $candidate.draft) {
429+
continue
430+
}
431+
if ([string]::IsNullOrWhiteSpace($candidate.tag_name)) {
432+
throw 'Cannot determine release source: a release has no tag_name.'
433+
}
434+
$parameters = @{
435+
Tag = $candidate.tag_name
436+
GetTagReference = $GetTagReference
437+
GetTagObject = $GetTagObject
438+
}
439+
$source = Resolve-PSModuleReleaseTagCommit @parameters
440+
if ($source -ceq $CommitSha) {
441+
$candidate
442+
}
443+
}
444+
}
445+
360446
function Get-FilesFromGitTree {
361447
<#
362448
.SYNOPSIS

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,9 @@ Set-GitHubLogGroup 'Calculate Job Run Conditions:' {
232232
$commitSha = if ($isPush) { $eventData.After ?? $env:GITHUB_SHA } else { $env:GITHUB_SHA }
233233
$pushBranch = if ($isPush) { $eventData.Ref -replace '^refs/heads/', '' } else { '' }
234234
$workflowRef = if ($isPush) { $pushBranch } else { $env:GITHUB_REF_NAME }
235-
$isPushToDefaultBranch = $isPush -and $pushBranch -eq $defaultBranch
236-
$isManualDispatchToDefaultBranch = $isManualDispatch -and $workflowRef -eq $defaultBranch
235+
$defaultBranchRef = "refs/heads/$defaultBranch"
236+
$isPushToDefaultBranch = $isPush -and $eventData.Ref -ceq $defaultBranchRef
237+
$isManualDispatchToDefaultBranch = $isManualDispatch -and $env:GITHUB_REF -ceq $defaultBranchRef
237238
$pullRequest = $eventData.PullRequest
238239

239240
$pullRequestIsClosed = $null -ne $pullRequest -and $pullRequest.State -eq 'closed'
@@ -375,6 +376,26 @@ If you believe this is incorrect, please verify that your changes are in the cor
375376
$response = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/commits/$Sha/pulls" -Method GET
376377
$response.Response
377378
}
379+
GetPublishedReleaseForCommit = {
380+
param($Sha)
381+
$owner = $env:GITHUB_REPOSITORY_OWNER
382+
$repo = $env:GITHUB_REPOSITORY_NAME
383+
$releases = (Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/releases?per_page=100" -Method GET -ErrorAction Stop).Response
384+
$parameters = @{
385+
CommitSha = $Sha
386+
Release = @($releases)
387+
GetTagReference = {
388+
param($Tag)
389+
$encodedTag = [Uri]::EscapeDataString($Tag)
390+
(Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/ref/tags/$encodedTag" -Method GET -ErrorAction Stop).Response.object
391+
}.GetNewClosure()
392+
GetTagObject = {
393+
param($TagSha)
394+
(Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/tags/$TagSha" -Method GET -ErrorAction Stop).Response.object
395+
}.GetNewClosure()
396+
}
397+
Get-PSModuleReleaseForCommit @parameters
398+
}
378399
}
379400
$resolution = Resolve-ReleasePullRequest @resolveParams
380401
if ($resolution.Resolved) {
@@ -397,6 +418,7 @@ If you believe this is incorrect, please verify that your changes are in the cor
397418
Title = $pullRequest.Title
398419
Body = $pullRequest.Body
399420
HeadRef = $pullRequest.Head.Ref
421+
HeadRepository = $pullRequest.Head.Repo.full_name
400422
BaseRef = $pullRequest.Base.Ref
401423
Labels = @($pullRequest.Labels.Name)
402424
Merged = $pullRequestIsMerged

0 commit comments

Comments
 (0)