diff --git a/scripts/Resolve-PSModuleVersion.Helpers.psm1 b/scripts/Resolve-PSModuleVersion.Helpers.psm1 index d4f2f45..4935a79 100644 --- a/scripts/Resolve-PSModuleVersion.Helpers.psm1 +++ b/scripts/Resolve-PSModuleVersion.Helpers.psm1 @@ -132,8 +132,9 @@ function Get-GitHubPullRequest { returns $null and the caller resolves the current version without a version bump. .OUTPUTS - PSCustomObject with HeadRef and Labels properties for a pull_request event, or - $null when the event has no pull request (non-PR events). + PSCustomObject with HeadRef, Labels, and IsOpen properties for a pull_request event, or + $null when the event has no pull request (non-PR events). IsOpen is $true only while the + pull request is open (under review); it is $false once the PR is merged or closed. .EXAMPLE $pullRequest = Get-GitHubPullRequest @@ -160,9 +161,17 @@ function Get-GitHubPullRequest { $labels = @() $labels += $pr.labels.name + # A pull request is OPEN while it is under review (opened/reopened/synchronize/labeled). + # Once it is merged or closed its state becomes 'closed'. Only an open pull request may + # resolve to a prerelease; a merged/closed PR - like a push to the default branch - never + # does. Default to not-open when state is missing so we never accidentally prerelease. + $isOpen = $pr.state -eq 'open' + Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ PRHeadRef = $pr.head.ref + State = $pr.state + IsOpen = $isOpen Labels = $labels -join ', ' } | Format-List | Out-String) Write-Host '-------------------------------------------------' @@ -170,6 +179,7 @@ function Get-GitHubPullRequest { [PSCustomObject]@{ HeadRef = $pr.head.ref Labels = $labels + IsOpen = $isOpen } } } @@ -181,7 +191,12 @@ function Resolve-ReleaseDecision { .DESCRIPTION Evaluates the PR labels against the configured label categories and release type - to produce a complete release decision. + to produce a complete release decision. A prerelease - whether a published prerelease or + a non-published preview - is only ever resolved for an OPEN pull request, so the artifact + built during review can never masquerade as a stable release before the merge decision is + made. Once a PR is merged or closed (and likewise for a push to the default branch, which + the workflow treats the same as a merged PR) the outcome is final: a full release, or the + current version unchanged - never a prerelease. .OUTPUTS PSCustomObject with ShouldPublish, CreateRelease, CreatePrerelease, MajorRelease, @@ -208,6 +223,7 @@ function Resolve-ReleaseDecision { $prereleaseName = $PullRequest.HeadRef -replace '[^a-zA-Z0-9]' $labels = $PullRequest.Labels $releaseType = $Configuration.ReleaseType + $isOpenPullRequest = [bool]$PullRequest.IsOpen $validReleaseTypes = @('Release', 'Prerelease', 'None') if ([string]::IsNullOrWhiteSpace($releaseType)) { @@ -217,15 +233,7 @@ function Resolve-ReleaseDecision { throw "Invalid ReleaseType: [$releaseType]. Valid values are: $($validReleaseTypes -join ', ')" } - $createRelease = $releaseType -eq 'Release' - $createPrerelease = $releaseType -eq 'Prerelease' - $shouldPublish = $createRelease -or $createPrerelease - $ignoreRelease = ($labels | Where-Object { $Configuration.IgnoreLabels -contains $_ }).Count -gt 0 - if ($ignoreRelease -and $shouldPublish) { - Write-Host 'Ignoring release creation due to ignore label.' - $shouldPublish = $false - } # Always evaluate the version-bump labels so the resolved version reflects what WOULD be # created, regardless of whether this run publishes. ReleaseType (and the prerelease label @@ -235,32 +243,51 @@ function Resolve-ReleaseDecision { $patchRelease = ( (($labels | Where-Object { $Configuration.PatchLabels -contains $_ }).Count -gt 0) -or $Configuration.AutoPatching ) -and -not $majorRelease -and -not $minorRelease - $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease - if (-not $hasVersionBump) { - # No explicit bump label and no AutoPatching: still resolve a patch version so the run - # can preview what it would create, but do not publish a full release for an unlabeled change. - Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version without publishing.' - $patchRelease = $true - $hasVersionBump = $true - $shouldPublish = $false - } - # Anything that is not a published full release is surfaced as a prerelease - either a - # published prerelease (ShouldPublish) or a non-published preview. - if (-not $shouldPublish) { + # A prerelease - whether a published prerelease or a non-published preview - only makes + # sense while a pull request is OPEN. It lets the artifact built during review carry a + # prerelease tag so it can never be mistaken for a stable release before the merge decision + # is made. Once the change lands on the default branch the outcome is final: a merged pull + # request publishes a full release (or, when nothing warrants one, nothing at all), and a + # push to the default branch is treated the same as a merged pull request. A merge or push + # therefore never resolves to a prerelease. + if ($isOpenPullRequest) { + # Open pull request: always carry a prerelease tag. ReleaseType 'Prerelease' (the + # prerelease label) publishes it; otherwise it is a non-published preview. An ignore + # label only suppresses publishing - the reviewed artifact still stays a prerelease. + $createRelease = $false $createPrerelease = $true + $shouldPublish = ($releaseType -eq 'Prerelease') -and -not $ignoreRelease + if (-not $hasVersionBump) { + Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version.' + $patchRelease = $true + $hasVersionBump = $true + } + } else { + # Merged/closed pull request or a push to the default branch: never a prerelease. + $createPrerelease = $false + $createRelease = ($releaseType -eq 'Release') -and -not $ignoreRelease -and $hasVersionBump + $shouldPublish = $createRelease + if (-not $createRelease) { + Write-Host 'No open pull request and nothing to release; keeping the current version.' + $majorRelease = $false + $minorRelease = $false + $patchRelease = $false + $hasVersionBump = $false + } } Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ - ReleaseType = $releaseType - ShouldPublish = $shouldPublish - CreateRelease = $createRelease - CreatePrerelease = $createPrerelease - Major = $majorRelease - Minor = $minorRelease - Patch = $patchRelease + ReleaseType = $releaseType + IsOpenPullRequest = $isOpenPullRequest + ShouldPublish = $shouldPublish + CreateRelease = $createRelease + CreatePrerelease = $createPrerelease + Major = $majorRelease + Minor = $minorRelease + Patch = $patchRelease } | Format-List | Out-String) Write-Host '-------------------------------------------------' diff --git a/tests/Resolve-PSModuleVersion.Helpers.Tests.Data.psd1 b/tests/Resolve-PSModuleVersion.Helpers.Tests.Data.psd1 index c08c11e..0b514d8 100644 --- a/tests/Resolve-PSModuleVersion.Helpers.Tests.Data.psd1 +++ b/tests/Resolve-PSModuleVersion.Helpers.Tests.Data.psd1 @@ -1,9 +1,10 @@ @{ ReleaseDecision = @( @{ - Name = 'Patch label with Release type' + Name = 'Merged PR with a patch label publishes a stable release' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false HeadRef = 'feat/test-patch' Labels = @('patch') Expected = @{ @@ -18,9 +19,10 @@ } } @{ - Name = 'Minor label with Release type' + Name = 'Merged PR with a minor label publishes a stable release' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false HeadRef = 'feat/test-minor' Labels = @('minor') Expected = @{ @@ -35,9 +37,10 @@ } } @{ - Name = 'Major label with Release type' + Name = 'Merged PR with a major label publishes a stable release' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false HeadRef = 'feat/test-major' Labels = @('major') Expected = @{ @@ -52,9 +55,10 @@ } } @{ - Name = 'AutoPatching with no label' + Name = 'Merged PR with AutoPatching and no label publishes a stable release' ReleaseType = 'Release' AutoPatching = $true + IsOpen = $false HeadRef = 'feat/test-autopatch' Labels = @() Expected = @{ @@ -69,26 +73,46 @@ } } @{ - Name = 'Ignore label suppresses release' + Name = 'Merged PR with an ignore label keeps the current version' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false HeadRef = 'feat/test-ignore' Labels = @('patch', 'skip-release') Expected = @{ ShouldPublish = $false - CreateRelease = $true - CreatePrerelease = $true + CreateRelease = $false + CreatePrerelease = $false MajorRelease = $false MinorRelease = $false - PatchRelease = $true - HasVersionBump = $true + PatchRelease = $false + HasVersionBump = $false PrereleaseName = 'feattestignore' } } @{ - Name = 'ReleaseType None produces prerelease' + Name = 'Merged PR with ReleaseType None keeps the current version' + ReleaseType = 'None' + AutoPatching = $true + IsOpen = $false + HeadRef = 'auto-update-20260712' + Labels = @('patch') + Expected = @{ + ShouldPublish = $false + CreateRelease = $false + CreatePrerelease = $false + MajorRelease = $false + MinorRelease = $false + PatchRelease = $false + HasVersionBump = $false + PrereleaseName = 'autoupdate20260712' + } + } + @{ + Name = 'Open PR with ReleaseType None previews a patch prerelease' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true HeadRef = 'feat/test-none' Labels = @('patch') Expected = @{ @@ -103,9 +127,10 @@ } } @{ - Name = 'ReleaseType None with a minor label previews a minor bump' + Name = 'Open PR with ReleaseType None previews a minor prerelease' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true HeadRef = 'feat/none-minor' Labels = @('minor') Expected = @{ @@ -120,9 +145,10 @@ } } @{ - Name = 'ReleaseType None with a major label previews a major bump' + Name = 'Open PR with ReleaseType None previews a major prerelease' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true HeadRef = 'feat/none-major' Labels = @('major') Expected = @{ @@ -137,9 +163,10 @@ } } @{ - Name = 'ReleaseType Prerelease with minor label' + Name = 'Open PR with ReleaseType Prerelease publishes a prerelease' ReleaseType = 'Prerelease' AutoPatching = $false + IsOpen = $true HeadRef = 'feat/add-prerelease-support' Labels = @('minor') Expected = @{ @@ -154,19 +181,38 @@ } } @{ - Name = 'No bump label and no AutoPatching falls back to prerelease' - ReleaseType = 'Release' + Name = 'Open PR with an ignore label still previews a prerelease' + ReleaseType = 'None' AutoPatching = $false - HeadRef = 'feat/no-label' - Labels = @() + IsOpen = $true + HeadRef = 'feat/preview-with-ignore' + Labels = @('patch', 'skip-release') Expected = @{ ShouldPublish = $false - CreateRelease = $true + CreateRelease = $false CreatePrerelease = $true MajorRelease = $false MinorRelease = $false PatchRelease = $true HasVersionBump = $true + PrereleaseName = 'featpreviewwithignore' + } + } + @{ + Name = 'Merged PR with no bump label and no AutoPatching keeps the current version' + ReleaseType = 'Release' + AutoPatching = $false + IsOpen = $false + HeadRef = 'feat/no-label' + Labels = @() + Expected = @{ + ShouldPublish = $false + CreateRelease = $false + CreatePrerelease = $false + MajorRelease = $false + MinorRelease = $false + PatchRelease = $false + HasVersionBump = $false PrereleaseName = 'featnolabel' } } @@ -357,10 +403,11 @@ EndToEnd = @( @{ - Name = 'Patch bump produces release version' + Name = 'Merged PR patch bump produces stable release version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-patch' @@ -372,10 +419,11 @@ ExpectedFullVersion = 'v1.0.2' } @{ - Name = 'Minor bump produces release version' + Name = 'Merged PR minor bump produces stable release version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-minor' @@ -387,10 +435,11 @@ ExpectedFullVersion = 'v1.1.0' } @{ - Name = 'Major bump produces release version' + Name = 'Merged PR major bump produces stable release version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-major' @@ -402,10 +451,11 @@ ExpectedFullVersion = 'v2.0.0' } @{ - Name = 'Auto-patch with no label produces release version' + Name = 'Merged PR auto-patch with no label produces stable release version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $true + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-autopatch' @@ -417,25 +467,43 @@ ExpectedFullVersion = 'v1.0.2' } @{ - Name = 'Ignore label suppresses release, produces prerelease' + Name = 'Merged PR with an ignore label keeps the current version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-ignore' Labels = @('patch', 'skip-release') ExpectedShouldPublish = $false ExpectedReleaseType = 'None' - ExpectedVersion = '1.0.2' - ExpectedPrerelease = 'feattestignore001' - ExpectedFullVersion = 'v1.0.2-feattestignore001' + ExpectedVersion = '1.0.1' + ExpectedPrerelease = '' + ExpectedFullVersion = 'v1.0.1' } @{ - Name = 'ReleaseType None produces prerelease with counter' + Name = 'Merged PR with ReleaseType None keeps the current version' + LatestVersion = '1.0.1' + ReleaseType = 'None' + AutoPatching = $true + IsOpen = $false + IncrementalPrerelease = $false + VersionPrefix = 'v' + HeadRef = 'auto-update-20260712' + Labels = @('patch') + ExpectedShouldPublish = $false + ExpectedReleaseType = 'None' + ExpectedVersion = '1.0.1' + ExpectedPrerelease = '' + ExpectedFullVersion = 'v1.0.1' + } + @{ + Name = 'Open PR with ReleaseType None previews a patch prerelease' LatestVersion = '1.0.1' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/test-none' @@ -447,10 +515,11 @@ ExpectedFullVersion = 'v1.0.2-feattestnone001' } @{ - Name = 'ReleaseType None with a minor label previews a minor prerelease' + Name = 'Open PR with ReleaseType None previews a minor prerelease' LatestVersion = '1.0.1' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/none-minor' @@ -462,10 +531,11 @@ ExpectedFullVersion = 'v1.1.0-featnoneminor001' } @{ - Name = 'ReleaseType None with a major label previews a major prerelease' + Name = 'Open PR with ReleaseType None previews a major prerelease' LatestVersion = '1.0.1' ReleaseType = 'None' AutoPatching = $false + IsOpen = $true IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/none-major' @@ -477,10 +547,11 @@ ExpectedFullVersion = 'v2.0.0-featnonemajor001' } @{ - Name = 'Prerelease type with incremental counter' + Name = 'Open PR with ReleaseType Prerelease publishes a prerelease' LatestVersion = '1.0.1' ReleaseType = 'Prerelease' AutoPatching = $false + IsOpen = $true IncrementalPrerelease = $true VersionPrefix = 'v' HeadRef = 'feat/add-prerelease-support' @@ -492,19 +563,36 @@ ExpectedFullVersion = 'v1.1.0-feataddprereleasesupport001' } @{ - Name = 'No label and no AutoPatching falls back to prerelease' + Name = 'Open PR with an ignore label still previews a prerelease' + LatestVersion = '1.0.1' + ReleaseType = 'None' + AutoPatching = $false + IsOpen = $true + IncrementalPrerelease = $false + VersionPrefix = 'v' + HeadRef = 'feat/preview-with-ignore' + Labels = @('patch', 'skip-release') + ExpectedShouldPublish = $false + ExpectedReleaseType = 'None' + ExpectedVersion = '1.0.2' + ExpectedPrerelease = 'featpreviewwithignore001' + ExpectedFullVersion = 'v1.0.2-featpreviewwithignore001' + } + @{ + Name = 'Merged PR with no bump and no AutoPatching keeps the current version' LatestVersion = '1.0.1' ReleaseType = 'Release' AutoPatching = $false + IsOpen = $false IncrementalPrerelease = $false VersionPrefix = 'v' HeadRef = 'feat/no-label' Labels = @() ExpectedShouldPublish = $false ExpectedReleaseType = 'None' - ExpectedVersion = '1.0.2' - ExpectedPrerelease = 'featnolabel001' - ExpectedFullVersion = 'v1.0.2-featnolabel001' + ExpectedVersion = '1.0.1' + ExpectedPrerelease = '' + ExpectedFullVersion = 'v1.0.1' } ) } diff --git a/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 b/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 index e028ef4..27f3a07 100644 --- a/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -59,6 +59,7 @@ Describe 'Resolve-ReleaseDecision' { $pullRequest = [PSCustomObject]@{ HeadRef = $HeadRef Labels = $Labels + IsOpen = $IsOpen } $result = Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest } @@ -153,6 +154,7 @@ Describe 'End-to-end: Resolve-ReleaseDecision + Get-NextModuleVersion' { $pullRequest = [PSCustomObject]@{ HeadRef = $HeadRef Labels = $Labels + IsOpen = $IsOpen } $decision = Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest $latestVer = New-PSSemVer -Version $LatestVersion @@ -245,10 +247,11 @@ Describe 'Get-GitHubPullRequest' { Get-GitHubPullRequest | Should -BeNullOrEmpty } - It 'returns the head ref and labels for a pull_request event' { + It 'returns the head ref, labels, and IsOpen true for an open pull_request event' { $eventJson = @{ pull_request = @{ head = @{ ref = 'feat/example' } + state = 'open' labels = @(@{ name = 'patch' }, @{ name = 'prerelease' }) } } | ConvertTo-Json -Depth 5 @@ -256,5 +259,22 @@ Describe 'Get-GitHubPullRequest' { $result = Get-GitHubPullRequest $result.HeadRef | Should -Be 'feat/example' ($result.Labels -join ',') | Should -Be 'patch,prerelease' + $result.IsOpen | Should -BeTrue + } + + It 'returns IsOpen false for a merged/closed pull_request event' { + $eventJson = @{ + action = 'closed' + pull_request = @{ + head = @{ ref = 'feat/example' } + state = 'closed' + merged = $true + labels = @(@{ name = 'patch' }) + } + } | ConvertTo-Json -Depth 5 + $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = $eventJson + $result = Get-GitHubPullRequest + $result.HeadRef | Should -Be 'feat/example' + $result.IsOpen | Should -BeFalse } }