diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index ef5aef29..4997b040 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -109,6 +109,170 @@ function Select-PullRequestForPush { Select-Object -First 1 } +function Get-DiscardedReleasePullRequest { + <# + .SYNOPSIS + Reports merged default-branch pull requests whose version label would be discarded. + + .DESCRIPTION + Select-PullRequestForPush returns nothing both when a commit has no associated pull request + and when every associated pull request fails its criteria. Only the first case is safe: a + commit pushed directly to the default branch has no label to honour, so the direct-release + path applies the default patch bump. + + The unsafe case is a pull request that was merged into the default branch, and therefore + carries the version label that was meant to drive the release, but was not selected because + its merge commit does not match the commit being released. Falling back to a patch bump + there publishes a version nobody asked for, and a PowerShell Gallery version cannot be + reclaimed, so the caller must fail instead. + + Pull requests that are not merged are ignored. The GitHub commit association endpoint also + returns open pull requests whose branch contains the commit, which is expected and carries + no release intent. + + .OUTPUTS + String, one description per merged pull request that was rejected. Nothing when the + associated pull requests carry no release intent. + + .EXAMPLE + Get-DiscardedReleasePullRequest -PullRequest $associated -DefaultBranch main -CommitSha $sha + + Returns '#412 was merged into [main] with merge commit [abc123]'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The pull requests the GitHub API associated with the commit. + [Parameter()] + [object[]] $PullRequest, + + # The repository default branch a release must target. + [Parameter(Mandatory)] + [string] $DefaultBranch, + + # The commit the workflow is resolving a release for. + [Parameter(Mandatory)] + [string] $CommitSha + ) + + foreach ($candidate in ($PullRequest | Where-Object { $null -ne $_ })) { + $isMergedToDefaultBranch = ( + $candidate.Base.Ref -eq $DefaultBranch -and + -not [string]::IsNullOrWhiteSpace($candidate.merged_at) + ) + if (-not $isMergedToDefaultBranch) { continue } + if ($candidate.merge_commit_sha -eq $CommitSha) { continue } + + "#$($candidate.Number) was merged into [$DefaultBranch] with merge commit [$($candidate.merge_commit_sha)]" + } +} + +function Resolve-ReleasePullRequest { + <# + .SYNOPSIS + Resolves the pull request whose version label drives the release for a commit. + + .DESCRIPTION + A push resolves the pull request associated with the pushed commit so a default-branch + release honours the merged pull request's version label. A manual dispatch on the default + branch is the documented recovery route for a failed or cancelled release run and targets + the same merge commit, so it must resolve the same pull request. Excluding it left the pull + request unresolved, and the version silently fell back to a patch bump through AutoPatching. + + When no pull request is selected, the outcome depends on why. A commit pushed directly to + the default branch has no label to honour, so the release proceeds with the default patch + bump. A commit associated with a merged default-branch pull request that does not match it + does carry a label, and applying a patch bump would publish a version nobody asked for. A + PowerShell Gallery version cannot be reclaimed, so that case throws instead. + + .OUTPUTS + PSCustomObject with Resolved, indicating whether the lookup ran, and PullRequest, which is + null when the commit has no associated release pull request. + + .EXAMPLE + Resolve-ReleasePullRequest -EventName workflow_dispatch -CommitSha $sha -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $true -GetAssociatedPullRequest { param($Sha) $pulls } + + Resolves the merged pull request for a recovery dispatch so its version label is honoured. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + Justification = 'Parameters are used inside a LogGroup script block.')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + # The name of the GitHub event that triggered the workflow. + [Parameter(Mandatory)] + [string] $EventName, + + # The commit the workflow is resolving a release for. + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string] $CommitSha, + + # The repository default branch a release must target. + [Parameter(Mandatory)] + [string] $DefaultBranch, + + # Whether the workflow was triggered by a push to the default branch. + [Parameter()] + [bool] $IsPushToDefaultBranch, + + # Whether the workflow was manually dispatched against the default branch. + [Parameter()] + [bool] $IsManualDispatchToDefaultBranch, + + # Returns the pull requests GitHub associates with a commit. Takes the commit SHA. + [Parameter(Mandatory)] + [scriptblock] $GetAssociatedPullRequest + ) + + $isPush = $EventName -eq 'push' + $shouldResolve = ( + ($isPush -or $IsManualDispatchToDefaultBranch) -and + -not [string]::IsNullOrWhiteSpace($CommitSha) + ) + if (-not $shouldResolve) { + return [pscustomobject]@{ Resolved = $false; PullRequest = $null } + } + + LogGroup "Resolve pull request for commit [$CommitSha]" { + $associated = @((& $GetAssociatedPullRequest $CommitSha) | Where-Object { $null -ne $_ }) + $pullRequest = Select-PullRequestForPush -PullRequest $associated ` + -DefaultBranch $DefaultBranch ` + -CommitSha $CommitSha + + if ($pullRequest) { + Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$CommitSha]." + return [pscustomobject]@{ Resolved = $true; PullRequest = $pullRequest } + } + + # Only a release-bearing event can publish a wrong version. A push to a feature branch has + # no release to get wrong, and its commit is legitimately claimed by an open pull request. + $isReleaseEvent = $IsPushToDefaultBranch -or $IsManualDispatchToDefaultBranch + $discarded = if ($isReleaseEvent) { + @(Get-DiscardedReleasePullRequest -PullRequest $associated ` + -DefaultBranch $DefaultBranch ` + -CommitSha $CommitSha) + } else { + @() + } + if ($discarded.Count -gt 0) { + throw ( + "Commit [$CommitSha] cannot be released because its version label cannot be determined. " + + 'The following merged pull request(s) are associated with it but none matches the commit ' + + "being released: $($discarded -join '; '). " + + 'Refusing to fall back to a patch bump, because a wrong version published to the ' + + 'PowerShell Gallery cannot be reclaimed. Re-run the workflow against the merge commit ' + + 'of the pull request you intend to release.' + ) + } + + Write-Host "::notice::No pull request is associated with commit [$CommitSha]." + [pscustomobject]@{ Resolved = $true; PullRequest = $null } + } +} + function Get-FilesFromGitTree { <# .SYNOPSIS diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index da1ad72b..5a66c41c 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -242,23 +242,29 @@ LogGroup 'Calculate Job Run Conditions:' { $isManualDispatchToDefaultBranch = $isManualDispatch -and $workflowRef -eq $defaultBranch $pullRequest = $eventData.PullRequest - if ($isPush -and $commitSha) { - LogGroup "Resolve pull request for commit [$commitSha]" { + # A manual dispatch on the default branch is the documented recovery route for a failed or + # cancelled release run. It targets the same merge commit as the push it replaces, so it must + # resolve the same pull request and honour the same version label. Gating this lookup on + # $isPush alone left $pullRequest null for a dispatch, which silently downgraded a labelled + # Major or Minor release to a Patch bump through the AutoPatching fallback. + $resolveParams = @{ + EventName = $eventName + CommitSha = $commitSha + DefaultBranch = $defaultBranch + IsPushToDefaultBranch = $isPushToDefaultBranch + IsManualDispatchToDefaultBranch = $isManualDispatchToDefaultBranch + GetAssociatedPullRequest = { + param($Sha) $owner = $env:GITHUB_REPOSITORY_OWNER $repo = $env:GITHUB_REPOSITORY_NAME - $response = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/commits/$commitSha/pulls" -Method GET - $associatedPullRequests = @($response.Response) - $pullRequest = Select-PullRequestForPush -PullRequest $associatedPullRequests ` - -DefaultBranch $defaultBranch ` - -CommitSha $commitSha - - if ($pullRequest) { - Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$commitSha]." - } else { - Write-Host "::notice::No pull request is associated with commit [$commitSha]." - } + $response = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/commits/$Sha/pulls" -Method GET + $response.Response } } + $resolution = Resolve-ReleasePullRequest @resolveParams + if ($resolution.Resolved) { + $pullRequest = $resolution.PullRequest + } $pullRequestIsMerged = if ($null -eq $pullRequest) { $false diff --git a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 index f7d1fa8e..df106eb5 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -126,6 +126,214 @@ Describe 'Select-PullRequestForPush' { } } +Describe 'Resolve-ReleasePullRequest' { + BeforeAll { + $script:mergedPullRequest = [pscustomobject]@{ + Number = 64 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-09-02T09:02:24Z' + merge_commit_sha = 'released-sha' + Labels = @([pscustomobject]@{ Name = 'minor' }) + } + } + + It 'resolves the merged pull request for a push to the default branch' { + $result = Resolve-ReleasePullRequest -EventName push ` + -CommitSha 'released-sha' ` + -DefaultBranch main ` + -IsPushToDefaultBranch $true ` + -GetAssociatedPullRequest { @($script:mergedPullRequest) } + + $result.Resolved | Should -BeTrue + $result.PullRequest.Number | Should -Be 64 + } + + It 'resolves the merged pull request for a manual dispatch on the default branch' { + # Regression guard for issue #530. Association was gated on the push event, so a recovery + # dispatch resolved no pull request, discarded the merged pull request's version label, and + # silently released a patch bump instead of the labeled minor bump. + $result = Resolve-ReleasePullRequest -EventName workflow_dispatch ` + -CommitSha 'released-sha' ` + -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $true ` + -GetAssociatedPullRequest { @($script:mergedPullRequest) } + + $result.Resolved | Should -BeTrue + $result.PullRequest.Number | Should -Be 64 + @($result.PullRequest.Labels.Name) | Should -Be @('minor') + } + + It 'does not look up a pull request for a manual dispatch outside the default branch' { + $lookups = @{ Count = 0 } + $result = Resolve-ReleasePullRequest -EventName workflow_dispatch ` + -CommitSha 'released-sha' ` + -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $false ` + -GetAssociatedPullRequest ({ $lookups.Count++; @() }.GetNewClosure()) + + $result.Resolved | Should -BeFalse + $result.PullRequest | Should -BeNullOrEmpty + $lookups.Count | Should -Be 0 + } + + It 'does not look up a pull request for a scheduled run' { + $lookups = @{ Count = 0 } + $result = Resolve-ReleasePullRequest -EventName schedule ` + -CommitSha 'released-sha' ` + -DefaultBranch main ` + -GetAssociatedPullRequest ({ $lookups.Count++; @() }.GetNewClosure()) + + $result.Resolved | Should -BeFalse + $lookups.Count | Should -Be 0 + } + + It 'does not look up a pull request without a commit' { + $lookups = @{ Count = 0 } + $result = Resolve-ReleasePullRequest -EventName workflow_dispatch ` + -CommitSha '' ` + -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $true ` + -GetAssociatedPullRequest ({ $lookups.Count++; @() }.GetNewClosure()) + + $result.Resolved | Should -BeFalse + $lookups.Count | Should -Be 0 + } + + It 'releases a directly pushed commit that has no associated pull request' { + $result = Resolve-ReleasePullRequest -EventName workflow_dispatch ` + -CommitSha 'direct-push-sha' ` + -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $true ` + -GetAssociatedPullRequest { @() } + + $result.Resolved | Should -BeTrue + $result.PullRequest | Should -BeNullOrEmpty + } + + It 'tolerates an association response that yields a null element' { + $result = Resolve-ReleasePullRequest -EventName push ` + -CommitSha 'direct-push-sha' ` + -DefaultBranch main ` + -IsPushToDefaultBranch $true ` + -GetAssociatedPullRequest { $null } + + $result.Resolved | Should -BeTrue + $result.PullRequest | Should -BeNullOrEmpty + } + + It 'fails rather than release a patch bump when a merged pull request does not match the commit' { + # A silently wrong version cannot be withdrawn from the PowerShell Gallery, so a commit whose + # version label cannot be determined must stop the run. + $otherMerge = [pscustomobject]@{ + Number = 412 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-09-01T00:00:00Z' + merge_commit_sha = 'merge-of-412' + } + + $getAssociated = { @($otherMerge) }.GetNewClosure() + { + Resolve-ReleasePullRequest -EventName workflow_dispatch ` + -CommitSha 'released-sha' ` + -DefaultBranch main ` + -IsManualDispatchToDefaultBranch $true ` + -GetAssociatedPullRequest $getAssociated + } | Should -Throw '*#412*merge-of-412*cannot be reclaimed*' + } + + It 'releases the default patch bump when only an open pull request claims the commit' { + # The commit association endpoint also returns open pull requests whose branch contains the + # commit. They carry no release intent and must not fail a direct default-branch push. + $openPullRequest = [pscustomobject]@{ + Number = 411 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = $null + merge_commit_sha = 'not-merged-yet' + } + + $result = Resolve-ReleasePullRequest -EventName push ` + -CommitSha 'direct-push-sha' ` + -DefaultBranch main ` + -IsPushToDefaultBranch $true ` + -GetAssociatedPullRequest ({ @($openPullRequest) }.GetNewClosure()) + + $result.Resolved | Should -BeTrue + $result.PullRequest | Should -BeNullOrEmpty + } + + It 'does not fail a feature-branch push whose commit belongs to another merged pull request' { + $otherMerge = [pscustomobject]@{ + Number = 412 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-09-01T00:00:00Z' + merge_commit_sha = 'merge-of-412' + } + + $result = Resolve-ReleasePullRequest -EventName push ` + -CommitSha 'feature-sha' ` + -DefaultBranch main ` + -IsPushToDefaultBranch $false ` + -GetAssociatedPullRequest ({ @($otherMerge) }.GetNewClosure()) + + $result.Resolved | Should -BeTrue + $result.PullRequest | Should -BeNullOrEmpty + } +} + +Describe 'Get-DiscardedReleasePullRequest' { + It 'reports a merged default-branch pull request that does not match the released commit' { + $pullRequests = @( + [pscustomobject]@{ + Number = 412 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-08-15T00:00:00Z' + merge_commit_sha = 'merge-of-412' + } + ) + + $result = @(Get-DiscardedReleasePullRequest -PullRequest $pullRequests -DefaultBranch main -CommitSha 'other-sha') + + $result.Count | Should -Be 1 + $result[0] | Should -BeLike '*#412*merge-of-412*' + } + + It 'reports nothing when the merged pull request matches the released commit' { + $pullRequests = @( + [pscustomobject]@{ + Number = 390 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-08-15T00:00:00Z' + merge_commit_sha = 'released-sha' + } + ) + + $result = @(Get-DiscardedReleasePullRequest -PullRequest $pullRequests -DefaultBranch main -CommitSha 'released-sha') + + $result.Count | Should -Be 0 + } + + It 'ignores a pull request merged into a branch other than the default branch' { + $pullRequests = @( + [pscustomobject]@{ + Number = 401 + Base = [pscustomobject]@{ Ref = 'release/1.x' } + merged_at = '2026-08-15T00:00:00Z' + merge_commit_sha = 'merge-of-401' + } + ) + + $result = @(Get-DiscardedReleasePullRequest -PullRequest $pullRequests -DefaultBranch main -CommitSha 'released-sha') + + $result.Count | Should -Be 0 + } + + It 'reports nothing for a commit with no associated pull request' { + $result = @(Get-DiscardedReleasePullRequest -PullRequest @() -DefaultBranch main -CommitSha 'direct-push-sha') + + $result.Count | Should -Be 0 + } +} + Describe 'Get-FilesFromGitTree' { It 'returns only files from a complete tree response' { $tree = [pscustomobject]@{ diff --git a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 index 4a16a2ea..80424da2 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -523,6 +523,46 @@ Describe 'Resolve-PSModuleVersion' { $result.Labels | Should -BeNullOrEmpty $result.IsDirectRelease | Should -BeTrue } + + It 'honours the version label from a pull request resolved by a default-branch dispatch' { + # Regression guard for issue #530. Before the fix, a manual dispatch resolved no pull + # request, so Context.PullRequest was null and the direct-release branch applied a patch + # bump. With the pull request resolved, the minor label must be carried through. + $settings = @{ + Context = @{ + IsPushToDefaultBranch = $false + IsManualDispatchToDefaultBranch = $true + DefaultBranch = 'main' + PullRequest = @{ + Number = 64 + HeadRef = 'feature/recovered-release' + Labels = @('minor') + } + } + } | ConvertTo-Json -Depth 5 + + $result = Get-GitHubPullRequest -SettingsJson $settings + + $result.Number | Should -Be 64 + $result.Labels | Should -Be @('minor') + $result.IsDirectRelease | Should -BeNullOrEmpty + } + + It 'creates default patch context for a direct default-branch dispatch' { + $settings = @{ + Context = @{ + IsPushToDefaultBranch = $false + IsManualDispatchToDefaultBranch = $true + DefaultBranch = 'main' + PullRequest = $null + } + } | ConvertTo-Json -Depth 5 + + $result = Get-GitHubPullRequest -SettingsJson $settings + + $result.Number | Should -BeNullOrEmpty + $result.IsDirectRelease | Should -BeTrue + } } Describe 'Resolve-ReleaseDecision' { @@ -534,6 +574,47 @@ Describe 'Resolve-PSModuleVersion' { $result.PatchRelease | Should -BeTrue } + It 'resolves the labeled minor version once a recovery dispatch supplies pull request context' { + # End-to-end guard for issue #530, using the reproduction numbers from + # MariusStorhaug/MariusTestModule: latest release v0.4.13 and merged pull request #64 + # labeled 'minor', so the correct release is 0.5.0. This asserts the version consequence + # of the two possible contexts a default-branch dispatch can produce. Get-PSModuleSettings + # decides which one it is; that decision is guarded by + # Test-ShouldResolveAssociatedPullRequest in the settings action tests. + $configuration = Get-TestConfiguration + $versionFor = { + param($SettingsJson) + $pullRequest = Get-GitHubPullRequest -SettingsJson $SettingsJson + $decision = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest + $resolved = Get-ResolvedModuleVersion -GitHubVersion ([PSSemVer]'0.4.13') ` + -PSGalleryVersion ([PSSemVer]'0.4.13') ` + -Decision $decision ` + -Configuration $configuration ` + -ModuleName 'MariusTestModule' + $resolved.ToString() + } + + $dispatchContext = @{ + IsPushToDefaultBranch = $false + IsManualDispatchToDefaultBranch = $true + DefaultBranch = 'main' + } + + $withoutPullRequest = & $versionFor (@{ + Context = $dispatchContext + @{ PullRequest = $null } + } | ConvertTo-Json -Depth 5) + $withPullRequest = & $versionFor (@{ + Context = $dispatchContext + @{ + PullRequest = @{ Number = 64; HeadRef = 'feature/recovered-release'; Labels = @('minor') } + } + } | ConvertTo-Json -Depth 5) + + # The silent downgrade the defect produced. + $withoutPullRequest | Should -Be 'v0.4.14' + # The version the merged pull request's label asks for. + $withPullRequest | Should -Be 'v0.5.0' + } + It 'does not publish an unlabeled prerelease when AutoPatching is disabled' { $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -AutoPatching $false -ReleaseType Prerelease) ` -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @() })