From 4fb660b58e0a12b742646b35832e8ea1e3a549a8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:40:07 +0200 Subject: [PATCH 01/21] Enforce namespaced module release decisions --- .../src/Get-PSModuleSettings.Helpers.psm1 | 109 ++++- .../src/Settings.schema.json | 24 - .../actions/Get-PSModuleSettings/src/main.ps1 | 27 +- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 159 +++++- .../Resolve-PSModuleVersion/action.yml | 5 + .../src/Resolve-PSModuleVersion.Helpers.psm1 | 344 ++++++++----- .../Resolve-PSModuleVersion/src/main.ps1 | 22 +- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 456 ++++++++++++++++-- 8 files changed, 892 insertions(+), 254 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index ef5aef29..79f33f9a 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -28,10 +28,7 @@ function Resolve-WorkflowEventRouting { [bool] $IsManualDispatchToDefaultBranch, [Parameter()] - [bool] $HasImportantChanges, - - [Parameter()] - [bool] $HasPrereleaseLabel + [bool] $HasImportantChanges ) $isPR = $EventName -eq 'pull_request' @@ -43,7 +40,6 @@ function Resolve-WorkflowEventRouting { $isClosedPR = $isPR -and $EventAction -eq 'closed' $isAbandonedPR = $isClosedPR -and -not $PullRequestIsMerged $isMergedPR = $isClosedPR -and $PullRequestIsMerged - $shouldPrerelease = $isOpenOrLabeledPR -and $HasPrereleaseLabel -and $HasImportantChanges $shouldRelease = ( ($IsPushToDefaultBranch -or $IsManualDispatchToDefaultBranch) -and $HasImportantChanges @@ -61,11 +57,8 @@ function Resolve-WorkflowEventRouting { IsTargetDefaultBranch = $IsTargetDefaultBranch IsPushToDefaultBranch = $IsPushToDefaultBranch IsManualDispatchToDefaultBranch = $IsManualDispatchToDefaultBranch - ShouldPrerelease = $shouldPrerelease ReleaseType = if ($shouldRelease) { 'Release' - } elseif ($shouldPrerelease) { - 'Prerelease' } else { 'None' } @@ -149,3 +142,103 @@ function Get-FilesFromGitHubComparison { $files | Select-Object -ExpandProperty filename } + +function Get-UnsupportedPSModuleReleaseSetting { + <# + .SYNOPSIS + Returns release settings removed from the Process-PSModule v9 contract. + + .DESCRIPTION + Inspects a publish-module settings object and returns any setting names that + configured automatic patching or custom release-label aliases before v9. + + .OUTPUTS + System.String + + .EXAMPLE + Get-UnsupportedPSModuleReleaseSetting -PublishModule $settings.Publish.Module + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + [AllowNull()] + [object] $PublishModule + ) + + if ($null -eq $PublishModule) { + return + } + + $settingNames = if ($PublishModule -is [System.Collections.IDictionary]) { + @($PublishModule.Keys) + } else { + @($PublishModule.PSObject.Properties.Name) + } + $unsupportedSettingNames = @( + 'AutoPatching' + 'MajorLabels' + 'MinorLabels' + 'PatchLabels' + 'PrereleaseLabels' + 'IgnoreLabels' + ) + + foreach ($settingName in $unsupportedSettingNames) { + if ($settingNames -contains $settingName) { + $settingName + } + } +} + +function Resolve-PSModulePublishState { + <# + .SYNOPSIS + Applies the resolved release decision to module and site publication state. + + .DESCRIPTION + Updates the runtime settings after Resolve-PSModuleVersion has decided + whether this run publishes a stable release, prerelease, or nothing. + Closed pull requests retain their cleanup-only path. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + .EXAMPLE + $params = @{ + Settings = $settings + ReleaseType = 'None' + ShouldPublish = $false + } + Resolve-PSModulePublishState @params + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $Settings, + + [Parameter(Mandatory)] + [ValidateSet('Release', 'Prerelease', 'None')] + [string] $ReleaseType, + + [Parameter(Mandatory)] + [bool] $ShouldPublish + ) + + $isCleanupOnly = ( + $Settings.Context.EventName -eq 'pull_request' -and + $Settings.Context.EventAction -eq 'closed' + ) + $cleanupEnabled = $isCleanupOnly -and [bool]$Settings.Publish.Module.AutoCleanup + $moduleEnabled = $ShouldPublish -or $cleanupEnabled + $siteDesired = $ReleaseType -eq 'Release' + + $Settings.Publish.Module.ReleaseType = $ReleaseType + $Settings.Publish.Module.Desired = $moduleEnabled + $Settings.Publish.Module.Enabled = $moduleEnabled + $Settings.Publish.Site.Desired = $siteDesired + $Settings.Publish.Site.Enabled = $siteDesired -and -not [bool]$Settings.Publish.Site.Skip + + $Settings +} diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index a51b90ac..f21d5a18 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -138,10 +138,6 @@ "type": "boolean", "description": "When enabled (default: true), automatically cleans up old prerelease tags when merging to main or when a PR is abandoned" }, - "AutoPatching": { - "type": "boolean", - "description": "Automatically apply patches" - }, "IncrementalPrerelease": { "type": "boolean", "description": "Use incremental prerelease versioning" @@ -154,26 +150,6 @@ "type": "string", "description": "Prefix for version tags" }, - "MajorLabels": { - "type": "string", - "description": "Comma-separated labels that trigger major version bump" - }, - "MinorLabels": { - "type": "string", - "description": "Comma-separated labels that trigger minor version bump" - }, - "PatchLabels": { - "type": "string", - "description": "Comma-separated labels that trigger patch version bump" - }, - "IgnoreLabels": { - "type": "string", - "description": "Comma-separated labels that prevent release" - }, - "PrereleaseLabels": { - "type": "string", - "description": "Comma-separated labels that trigger a prerelease" - }, "UsePRTitleAsReleaseName": { "type": "boolean", "description": "Use pull request title as the GitHub release name" diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index da1ad72b..273b8ee8 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -73,6 +73,17 @@ if (![string]::IsNullOrEmpty($settingsPath) -and (Test-Path -Path $settingsPath) $settings = @{} } +$unsupportedReleaseSettings = @( + Get-UnsupportedPSModuleReleaseSetting -PublishModule $settings.Publish.Module +) +if ($unsupportedReleaseSettings.Count -gt 0) { + throw ( + "Unsupported Process-PSModule v9 release settings: [$($unsupportedReleaseSettings -join ', ')]. " + + 'Remove these settings. Release decisions now use only release:patch, release:minor, ' + + 'release:major, release:pre-release, and release:skip.' + ) +} + LogGroup 'Name' { [pscustomobject]@{ InputName = $name @@ -190,15 +201,9 @@ $settings = [pscustomobject]@{ Module = [pscustomobject]@{ Skip = $settings.Publish.Module.Skip ?? $false AutoCleanup = $settings.Publish.Module.AutoCleanup ?? $true - AutoPatching = $settings.Publish.Module.AutoPatching ?? $true IncrementalPrerelease = $settings.Publish.Module.IncrementalPrerelease ?? $true DatePrereleaseFormat = $settings.Publish.Module.DatePrereleaseFormat ?? '' VersionPrefix = $settings.Publish.Module.VersionPrefix ?? 'v' - MajorLabels = $settings.Publish.Module.MajorLabels ?? 'major, breaking' - MinorLabels = $settings.Publish.Module.MinorLabels ?? 'minor, feature' - PatchLabels = $settings.Publish.Module.PatchLabels ?? 'patch, fix' - IgnoreLabels = $settings.Publish.Module.IgnoreLabels ?? 'NoRelease' - PrereleaseLabels = $settings.Publish.Module.PrereleaseLabels ?? 'prerelease' UsePRTitleAsReleaseName = $settings.Publish.Module.UsePRTitleAsReleaseName ?? $false UsePRBodyAsReleaseNotes = $settings.Publish.Module.UsePRBodyAsReleaseNotes ?? $true UsePRTitleAsNotesHeading = $settings.Publish.Module.UsePRTitleAsNotesHeading ?? $true @@ -319,11 +324,6 @@ LogGroup 'Calculate Job Run Conditions:' { AssociatedPullRequest = $pullRequestContext.Number } | Format-List | Out-String - # Check if a prerelease label exists on the PR - $prereleaseLabels = $settings.Publish.Module.PrereleaseLabels -split ',' | ForEach-Object { $_.Trim() } - $prLabels = @($pullRequestContext.Labels) - $hasPrereleaseLabel = ($prLabels | Where-Object { $prereleaseLabels -contains $_ }).Count -gt 0 - # Check if important files have changed in the PR # Important files are determined by the configured ImportantFilePatterns setting $hasImportantChanges = $false @@ -449,8 +449,7 @@ If you believe this is incorrect, please verify that your changes are in the cor -IsTargetDefaultBranch $isTargetDefaultBranch ` -IsPushToDefaultBranch $isPushToDefaultBranch ` -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` - -HasImportantChanges $hasImportantChanges ` - -HasPrereleaseLabel $hasPrereleaseLabel + -HasImportantChanges $hasImportantChanges $releaseType = $routing.ReleaseType [pscustomobject]@{ @@ -464,8 +463,6 @@ If you believe this is incorrect, please verify that your changes are in the cor isManualDispatch = $routing.IsManualDispatch isPushToDefaultBranch = $routing.IsPushToDefaultBranch isTargetDefaultBranch = $routing.IsTargetDefaultBranch - hasPrereleaseLabel = $hasPrereleaseLabel - shouldPrerelease = $routing.ShouldPrerelease ReleaseType = $releaseType HasImportantChanges = $hasImportantChanges } | Format-List | Out-String 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..3d6623b2 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -1,3 +1,10 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in BeforeEach and used inside It blocks.' +)] +[CmdletBinding()] +param() + BeforeAll { Import-Module "$PSScriptRoot/../src/Get-PSModuleSettings.Helpers.psm1" -Force } @@ -42,14 +49,13 @@ Describe 'Resolve-WorkflowEventRouting' { $result.ReleaseType | Should -Be 'Release' } - It 'routes a labeled open PR with important changes to a prerelease' { + It 'leaves open-PR publication for the version resolver' { $result = Resolve-WorkflowEventRouting -EventName pull_request ` -EventAction labeled ` -IsTargetDefaultBranch $true ` - -HasImportantChanges $true ` - -HasPrereleaseLabel $true + -HasImportantChanges $true - $result.ReleaseType | Should -Be 'Prerelease' + $result.ReleaseType | Should -Be 'None' $result.ShouldRunBuildTest | Should -BeTrue } @@ -71,8 +77,7 @@ Describe 'Resolve-WorkflowEventRouting' { -EventAction labeled ` -PullRequestIsClosed $true ` -IsTargetDefaultBranch $true ` - -HasImportantChanges $true ` - -HasPrereleaseLabel $true + -HasImportantChanges $true $result.ReleaseType | Should -Be 'None' $result.IsOpenOrUpdatedPR | Should -BeFalse @@ -82,6 +87,148 @@ Describe 'Resolve-WorkflowEventRouting' { } } +Describe 'Get-UnsupportedPSModuleReleaseSetting' { + It 'returns no settings when the publish configuration uses the v9 contract' { + $publishModule = [pscustomobject]@{ + AutoCleanup = $true + IncrementalPrerelease = $true + VersionPrefix = 'v' + } + + @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule).Count | + Should -Be 0 + } + + It 'returns the removed setting from a PSCustomObject' -ForEach @( + @{ Name = 'AutoPatching' } + @{ Name = 'MajorLabels' } + @{ Name = 'MinorLabels' } + @{ Name = 'PatchLabels' } + @{ Name = 'PrereleaseLabels' } + @{ Name = 'IgnoreLabels' } + ) { + $publishModule = [pscustomobject]@{} + $publishModule | Add-Member -MemberType NoteProperty -Name $Name -Value 'legacy' + + Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule | + Should -BeExactly $Name + } + + It 'returns every removed setting from a dictionary in migration order' { + $publishModule = @{ + IgnoreLabels = 'NoRelease' + AutoPatching = $true + PrereleaseLabels = 'Prerelease' + MajorLabels = 'Major' + MinorLabels = 'Minor' + PatchLabels = 'Patch' + } + + @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule) | + Should -Be @( + 'AutoPatching' + 'MajorLabels' + 'MinorLabels' + 'PatchLabels' + 'PrereleaseLabels' + 'IgnoreLabels' + ) + } + + It 'rejects removed setting names case-insensitively' { + $publishModule = @{ autopatching = $true } + + Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule | + Should -BeExactly 'AutoPatching' + } + + It 'accepts a null publish configuration' { + @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $null).Count | + Should -Be 0 + } +} + +Describe 'Resolve-PSModulePublishState' { + BeforeEach { + $settings = [pscustomobject]@{ + Context = [pscustomobject]@{ + EventName = 'push' + EventAction = '' + } + Publish = [pscustomobject]@{ + Module = [pscustomobject]@{ + AutoCleanup = $true + ReleaseType = 'Release' + Desired = $true + Enabled = $true + } + Site = [pscustomobject]@{ + Skip = $false + Desired = $true + Enabled = $true + } + } + } + } + + It 'enables module and site publication for a stable release' { + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType Release -ShouldPublish $true + + $result.Publish.Module.ReleaseType | Should -BeExactly 'Release' + $result.Publish.Module.Enabled | Should -BeTrue + $result.Publish.Site.Enabled | Should -BeTrue + } + + It 'enables only module publication for a prerelease' { + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType Prerelease -ShouldPublish $true + + $result.Publish.Module.Enabled | Should -BeTrue + $result.Publish.Site.Enabled | Should -BeFalse + } + + It 'disables all publication for release:skip' { + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType None -ShouldPublish $false + + $result.Publish.Module.Enabled | Should -BeFalse + $result.Publish.Site.Enabled | Should -BeFalse + } + + It 'respects the site publication setting for a stable release' { + $settings.Publish.Site.Skip = $true + + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType Release -ShouldPublish $true + + $result.Publish.Module.Enabled | Should -BeTrue + $result.Publish.Site.Enabled | Should -BeFalse + } + + It 'retains closed-pull-request cleanup without enabling site publication' { + $settings.Context.EventName = 'pull_request' + $settings.Context.EventAction = 'closed' + + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType None -ShouldPublish $false + + $result.Publish.Module.Enabled | Should -BeTrue + $result.Publish.Site.Enabled | Should -BeFalse + } + + It 'disables closed-pull-request cleanup when AutoCleanup is disabled' { + $settings.Context.EventName = 'pull_request' + $settings.Context.EventAction = 'closed' + $settings.Publish.Module.AutoCleanup = $false + + $result = Resolve-PSModulePublishState -Settings $settings ` + -ReleaseType None -ShouldPublish $false + + $result.Publish.Module.Enabled | Should -BeFalse + } +} + Describe 'Select-PullRequestForPush' { It 'selects the merged PR whose merge commit matches the pushed commit' { $pullRequests = @( diff --git a/.github/actions/Resolve-PSModuleVersion/action.yml b/.github/actions/Resolve-PSModuleVersion/action.yml index 5192f28f..54cbb8dc 100644 --- a/.github/actions/Resolve-PSModuleVersion/action.yml +++ b/.github/actions/Resolve-PSModuleVersion/action.yml @@ -32,6 +32,10 @@ inputs: description: GitHub event payload as a JSON string. When set, overrides reading from the event file. Use for testing. required: false default: '' + ReleaseDecision: + description: Explicit decision for a direct push or workflow dispatch - release:patch, release:minor, release:major, or release:skip. + required: false + default: '' outputs: Version: @@ -69,5 +73,6 @@ runs: PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_Settings: ${{ inputs.Settings }} PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_Name: ${{ inputs.Name }} PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson: ${{ inputs.EventJson }} + PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_ReleaseDecision: ${{ inputs.ReleaseDecision }} GITHUB_EVENT_PATH: ${{ inputs.EventPath || github.event_path }} run: ${{ github.action_path }}/src/main.ps1 diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index ab9b8560..23aee4c4 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -1,25 +1,4 @@ -function Split-CommaSeparatedList { - <# - .SYNOPSIS - Splits a comma-separated string into a trimmed, non-empty array. - - .EXAMPLE - Split-CommaSeparatedList -Value 'Major, Minor, Patch' - - Returns @('Major', 'Minor', 'Patch'). - #> - [CmdletBinding()] - [OutputType([string[]])] - param( - # The comma-separated string to split. - [Parameter()] - [string] $Value - ) - - ($Value -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } -} - -function Read-ActionInput { +function Read-ActionInput { <# .SYNOPSIS Reads and validates action inputs from environment variables. @@ -29,7 +8,7 @@ function Read-ActionInput { Falls back to the repository name when the module name input is not provided. .OUTPUTS - PSCustomObject with Name and SettingsJson properties. + PSCustomObject with Name, SettingsJson, and ReleaseDecision properties. .EXAMPLE $actionInput = Read-ActionInput @@ -54,8 +33,9 @@ function Read-ActionInput { } [PSCustomObject]@{ - Name = $name - SettingsJson = $settingsJson + Name = $name + SettingsJson = $settingsJson + ReleaseDecision = [string]$env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_ReleaseDecision } } } @@ -66,8 +46,7 @@ function Get-PublishConfiguration { Parses the settings JSON into a publish configuration object. .DESCRIPTION - Extracts publish module settings including auto-patching flags, version prefix, - release type, and label classification arrays. + Extracts publish module settings used to format versions and prereleases. .OUTPUTS PSCustomObject with publish configuration properties. @@ -91,28 +70,16 @@ function Get-PublishConfiguration { $publishModule = $settings.Publish.Module $config = [PSCustomObject]@{ - AutoPatching = [bool]$publishModule.AutoPatching IncrementalPrerelease = [bool]$publishModule.IncrementalPrerelease DatePrereleaseFormat = [string]$publishModule.DatePrereleaseFormat VersionPrefix = [string]$publishModule.VersionPrefix - ReleaseType = [string]$publishModule.ReleaseType - IgnoreLabels = Split-CommaSeparatedList ([string]$publishModule.IgnoreLabels) - MajorLabels = Split-CommaSeparatedList ([string]$publishModule.MajorLabels) - MinorLabels = Split-CommaSeparatedList ([string]$publishModule.MinorLabels) - PatchLabels = Split-CommaSeparatedList ([string]$publishModule.PatchLabels) } Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ - AutoPatching = $config.AutoPatching IncrementalPrerelease = $config.IncrementalPrerelease DatePrereleaseFormat = $config.DatePrereleaseFormat VersionPrefix = $config.VersionPrefix - ReleaseType = $config.ReleaseType - IgnoreLabels = $config.IgnoreLabels -join ', ' - MajorLabels = $config.MajorLabels -join ', ' - MinorLabels = $config.MinorLabels -join ', ' - PatchLabels = $config.PatchLabels -join ', ' } | Format-List | Out-String) Write-Host '-------------------------------------------------' @@ -120,25 +87,25 @@ function Get-PublishConfiguration { } } -function Get-GitHubPullRequest { +function Get-ReleaseContext { <# .SYNOPSIS - Reads normalized pull-request context from settings, with event-payload fallback. + Reads normalized release context from settings, with event-payload fallback. .DESCRIPTION - The settings action resolves the pull request associated with a default-branch push - before this action runs. When no pull request exists, a direct push or manual - dispatch on the default branch still receives release context so it resolves the - default patch bump. + The settings action resolves a pull request associated with a default-branch + push before this action runs. Pull requests use canonical labels, while a + release-capable event without a pull request uses the explicit action input. .OUTPUTS - PSCustomObject with pull-request metadata, or a default-branch direct-release - context with no pull-request number. + PSCustomObject describing whether the run is a pull request, stable release, + cleanup, or validation context. .EXAMPLE - $pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson + $releaseContext = Get-ReleaseContext -SettingsJson $actionInput.SettingsJson ` + -ReleaseDecision $actionInput.ReleaseDecision #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'SettingsJson', + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Parameter is used inside a LogGroup script block.')] [CmdletBinding()] [OutputType([PSCustomObject])] @@ -146,7 +113,12 @@ function Get-GitHubPullRequest { # The complete settings object, including normalized workflow context. [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $SettingsJson + [string] $SettingsJson, + + # Explicit canonical decision for a release-capable event without a pull request. + [Parameter()] + [AllowEmptyString()] + [string] $ReleaseDecision = '' ) LogGroup 'Event information' { @@ -154,24 +126,91 @@ function Get-GitHubPullRequest { $context = $settings.Context if ($context) { $contextPullRequest = $context.PullRequest + $eventName = [string]$context.EventName + $eventAction = [string]$context.EventAction + $isCleanupOnly = $eventName -eq 'pull_request' -and $eventAction -eq 'closed' + + if ($isCleanupOnly) { + Write-Host 'Using cleanup-only closed pull request context.' + return [PSCustomObject]@{ + Type = 'Cleanup' + DecisionSource = 'None' + RequiresDecision = $false + CanPublish = $false + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $contextPullRequest.Number + HeadRef = [string]$contextPullRequest.HeadRef + Labels = @() + IsDirectRelease = $false + } + } + if ($contextPullRequest) { + if ($eventName -notin @('pull_request', 'push')) { + Write-Host "Ignoring pull request data in unsupported [$eventName] release context." + return [PSCustomObject]@{ + Type = 'Validation' + DecisionSource = 'None' + RequiresDecision = $false + CanPublish = $false + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $contextPullRequest.Number + HeadRef = [string]$contextPullRequest.HeadRef + Labels = @() + IsDirectRelease = $false + } + } + Write-Host "Using normalized pull request context for #$($contextPullRequest.Number)." + $isStableContext = $eventName -eq 'push' return [PSCustomObject]@{ - Number = $contextPullRequest.Number - HeadRef = $contextPullRequest.HeadRef - Labels = @($contextPullRequest.Labels) + Type = if ($isStableContext) { 'Stable' } else { 'PullRequest' } + DecisionSource = 'Labels' + RequiresDecision = $true + CanPublish = if ($isStableContext) { + [bool]$context.IsPushToDefaultBranch + } else { + $true + } + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $contextPullRequest.Number + HeadRef = [string]$contextPullRequest.HeadRef + Labels = @($contextPullRequest.Labels) + IsDirectRelease = $false } } - if ($context.IsPushToDefaultBranch -or $context.IsManualDispatchToDefaultBranch) { - Write-Host 'Using direct default-branch release context with the default patch bump.' + if ($eventName -in @('push', 'workflow_dispatch')) { + Write-Host "Using explicit [$eventName] release context." return [PSCustomObject]@{ - Number = $null - HeadRef = $context.DefaultBranch - Labels = @() - IsDirectRelease = $true + Type = 'Stable' + DecisionSource = 'Input' + RequiresDecision = $true + CanPublish = [bool]( + $context.IsPushToDefaultBranch -or + $context.IsManualDispatchToDefaultBranch + ) + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $null + HeadRef = [string]$context.DefaultBranch + Labels = @() + ExplicitDecision = $ReleaseDecision + IsDirectRelease = $true } } + + Write-Host "Using validation-only [$eventName] context." + return [PSCustomObject]@{ + Type = 'Validation' + DecisionSource = 'None' + RequiresDecision = $false + CanPublish = $false + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $null + HeadRef = [string]$context.DefaultBranch + Labels = @() + IsDirectRelease = $false + } } $eventJsonInput = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson @@ -183,8 +222,23 @@ function Get-GitHubPullRequest { $pr = $githubEvent.pull_request if (-not $pr) { + $eventName = [string]$env:GITHUB_EVENT_NAME + if ($eventName -in @('push', 'workflow_dispatch')) { + throw "Cannot authorize [$eventName] publication without normalized default-branch context." + } + Write-Host 'GitHub event does not contain pull_request data and no release context was normalized.' - return $null + return [PSCustomObject]@{ + Type = 'Validation' + DecisionSource = 'None' + RequiresDecision = $false + CanPublish = $false + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $null + HeadRef = '' + Labels = @() + IsDirectRelease = $false + } } $labels = @() @@ -197,9 +251,22 @@ function Get-GitHubPullRequest { } | Format-List | Out-String) Write-Host '-------------------------------------------------' + $isCleanupOnly = [string]$githubEvent.action -eq 'closed' + $defaultBranch = [string]$githubEvent.repository.default_branch + $targetsDefaultBranch = ( + -not [string]::IsNullOrWhiteSpace($defaultBranch) -and + [string]$pr.base.ref -eq $defaultBranch + ) [PSCustomObject]@{ - HeadRef = $pr.head.ref - Labels = $labels + Type = if ($isCleanupOnly) { 'Cleanup' } else { 'PullRequest' } + DecisionSource = if ($isCleanupOnly) { 'None' } else { 'Labels' } + RequiresDecision = -not $isCleanupOnly + CanPublish = -not $isCleanupOnly -and $targetsDefaultBranch + HasImportantChanges = [bool]$settings.HasImportantChanges + Number = $pr.number + HeadRef = [string]$pr.head.ref + Labels = if ($isCleanupOnly) { @() } else { $labels } + IsDirectRelease = $false } } } @@ -210,49 +277,29 @@ function Resolve-ReleaseDecision { Determines whether to publish a release and what kind of version bump to apply. .DESCRIPTION - Evaluates the PR labels against the configured label categories and release type - to produce a complete release decision. + Evaluates only the canonical release labels or the explicit non-PR input. + Missing, conflicting, and invalid release-capable decisions fail closed. .OUTPUTS PSCustomObject with ShouldPublish, CreateRelease, CreatePrerelease, MajorRelease, MinorRelease, PatchRelease, HasVersionBump, and PrereleaseName properties. .EXAMPLE - $decision = Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest + $decision = Resolve-ReleaseDecision -ReleaseContext $releaseContext #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Parameter is used inside LogGroup script block.')] [CmdletBinding()] [OutputType([PSCustomObject])] param( - # The publish configuration object. + # The normalized release context. [Parameter(Mandatory)] - [PSCustomObject] $Configuration, - - # The pull request data object. - [Parameter(Mandatory)] - [PSCustomObject] $PullRequest + [PSCustomObject] $ReleaseContext ) LogGroup 'Determine release configuration' { - $prereleaseName = $PullRequest.HeadRef -replace '[^a-zA-Z0-9]' - $labels = $PullRequest.Labels - $releaseType = $Configuration.ReleaseType - - $validReleaseTypes = @('Release', 'Prerelease', 'None') - if ([string]::IsNullOrWhiteSpace($releaseType)) { - throw "Settings.Publish.Module.ReleaseType is required. Valid values are: $($validReleaseTypes -join ', ')" - } - if ($releaseType -notin $validReleaseTypes) { - throw "Invalid ReleaseType: [$releaseType]. Valid values are: $($validReleaseTypes -join ', ')" - } - - $createRelease = $releaseType -eq 'Release' - $createPrerelease = $releaseType -eq 'Prerelease' - $shouldPublish = $createRelease -or $createPrerelease - $isCleanupOnly = $releaseType -eq 'None' - - if ($isCleanupOnly) { + $prereleaseName = [string]$ReleaseContext.HeadRef -replace '[^a-zA-Z0-9]' + if (-not $ReleaseContext.RequiresDecision) { return [PSCustomObject]@{ ShouldPublish = $false CreateRelease = $false @@ -262,63 +309,94 @@ function Resolve-ReleaseDecision { PatchRelease = $false HasVersionBump = $false PrereleaseName = $prereleaseName + SkipRelease = $false + Bump = 'None' } } - $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 - } - - $majorLabels = @($labels | Where-Object { $Configuration.MajorLabels -contains $_ }) - $minorLabels = @($labels | Where-Object { $Configuration.MinorLabels -contains $_ }) - $patchLabels = @($labels | Where-Object { $Configuration.PatchLabels -contains $_ }) - $versionLabels = @($majorLabels + $minorLabels + $patchLabels) - - if ($versionLabels.Count -gt 1) { - throw "Conflicting version labels: [$($versionLabels -join ', ')]. Apply exactly one version label." + $bumpLabelTypes = [ordered]@{ + 'release:patch' = 'Patch' + 'release:minor' = 'Minor' + 'release:major' = 'Major' } - if ($ignoreRelease -and $versionLabels.Count -gt 0) { - throw "The ignore label cannot be combined with a version label: [$($versionLabels -join ', ')]." - } - - $majorRelease = $majorLabels.Count -eq 1 - $minorRelease = $minorLabels.Count -eq 1 - $isDirectStableRelease = $createRelease -and $PullRequest.IsDirectRelease - $patchRelease = $patchLabels.Count -eq 1 -or ( - -not $majorRelease -and - -not $minorRelease -and - ($Configuration.AutoPatching -or $isDirectStableRelease) + $ownedLabelNames = @($bumpLabelTypes.Keys) + @('release:pre-release', 'release:skip') + $ownedLabels = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal ) - $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease - if (-not $hasVersionBump) { - Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version without publishing.' - $patchRelease = $true - $hasVersionBump = $true - $shouldPublish = $false + if ($ReleaseContext.DecisionSource -eq 'Input') { + $explicitDecision = [string]$ReleaseContext.ExplicitDecision + $validInputDecisions = @($bumpLabelTypes.Keys) + @('release:skip') + if ($validInputDecisions -cnotcontains $explicitDecision) { + throw ( + "Invalid or missing ReleaseDecision: [$explicitDecision]. Specify exactly one of " + + "$($validInputDecisions -join ', ')." + ) + } + $null = $ownedLabels.Add($explicitDecision) + } else { + foreach ($label in @($ReleaseContext.Labels)) { + if ($ownedLabelNames -ccontains $label) { + $null = $ownedLabels.Add($label) + } + } } - if ($ignoreRelease) { - $createRelease = $false - $createPrerelease = $false - $shouldPublish = $false + $hasSkip = $ownedLabels.Contains('release:skip') + $hasPrerelease = $ownedLabels.Contains('release:pre-release') + $bumpLabels = @($bumpLabelTypes.Keys | Where-Object { $ownedLabels.Contains($_) }) + + if ($hasSkip) { + if ($ownedLabels.Count -ne 1) { + throw 'Invalid release labels: release:skip must not be combined with another release label.' + } + } elseif ($bumpLabels.Count -eq 0) { + if ($hasPrerelease) { + throw 'Invalid release labels: release:pre-release requires exactly one release bump label.' + } + throw ( + 'Release decision is missing. Apply exactly one of release:patch, release:minor, ' + + 'release:major, or release:skip.' + ) + } elseif ($bumpLabels.Count -gt 1) { + throw "Conflicting release bump labels: [$($bumpLabels -join ', ')]. Apply exactly one bump label." } - if (-not $shouldPublish) { - $createPrerelease = $true + $bump = if ($bumpLabels.Count -eq 1) { $bumpLabelTypes[$bumpLabels[0]] } else { 'None' } + $majorRelease = $bump -eq 'Major' + $minorRelease = $bump -eq 'Minor' + $patchRelease = $bump -eq 'Patch' + $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease + $canPublish = [bool]$ReleaseContext.CanPublish -and [bool]$ReleaseContext.HasImportantChanges + $createRelease = -not $hasSkip -and $ReleaseContext.Type -eq 'Stable' -and $canPublish + $publishPrerelease = ( + -not $hasSkip -and + $ReleaseContext.Type -eq 'PullRequest' -and + $hasPrerelease -and + $canPublish + ) + $shouldPublish = $createRelease -or $publishPrerelease + $createPrerelease = ( + -not $hasSkip -and + $ReleaseContext.Type -eq 'PullRequest' -and + $hasVersionBump + ) + if ($createPrerelease -and [string]::IsNullOrWhiteSpace($prereleaseName)) { + throw 'Cannot create a pull-request preview version because the head branch name is missing.' } Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ - ReleaseType = $releaseType - ShouldPublish = $shouldPublish - CreateRelease = $createRelease - CreatePrerelease = $createPrerelease - Major = $majorRelease - Minor = $minorRelease - Patch = $patchRelease + ContextType = $ReleaseContext.Type + ShouldPublish = $shouldPublish + CreateRelease = $createRelease + PublishPrerelease = $publishPrerelease + PreviewVersion = $createPrerelease + Skip = $hasSkip + Bump = $bump + Major = $majorRelease + Minor = $minorRelease + Patch = $patchRelease } | Format-List | Out-String) Write-Host '-------------------------------------------------' @@ -331,6 +409,8 @@ function Resolve-ReleaseDecision { PatchRelease = $patchRelease HasVersionBump = $hasVersionBump PrereleaseName = $prereleaseName + SkipRelease = $hasSkip + Bump = $bump } } } diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index ad3b4a71..dfc611d2 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -8,25 +8,9 @@ Import-Module -Name "$PSScriptRoot/Resolve-PSModuleVersion.Helpers.psm1" -Force $actionInput = Read-ActionInput $config = Get-PublishConfiguration -SettingsJson $actionInput.SettingsJson -$pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson - -$decision = if ($null -eq $pullRequest) { - # Non-PR event (for example workflow_dispatch or schedule): there are no pull request - # labels to determine a version bump, so keep the current published version and publish - # nothing. For a module that has never been released this floors at 0.0.0. - [PSCustomObject]@{ - ShouldPublish = $false - CreateRelease = $false - CreatePrerelease = $false - MajorRelease = $false - MinorRelease = $false - PatchRelease = $false - HasVersionBump = $false - PrereleaseName = '' - } -} else { - Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest -} +$releaseContext = Get-ReleaseContext -SettingsJson $actionInput.SettingsJson ` + -ReleaseDecision $actionInput.ReleaseDecision +$decision = Resolve-ReleaseDecision -ReleaseContext $releaseContext $releases = @(Get-GitHubRelease) $ghVersion = Get-LatestGitHubVersion -Releases $releases 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..3993d52a 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -2,6 +2,10 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingCmdletAliases', 'Context', + Justification = 'Context is part of the Pester DSL, not the Get-Context alias.' +)] [CmdletBinding()] param() @@ -17,10 +21,6 @@ BeforeAll { [CmdletBinding()] [OutputType([PSCustomObject])] param( - # Whether an unlabeled pull request is treated as a patch. - [Parameter()] - [bool] $AutoPatching = $true, - # Whether prereleases get an incrementing number suffix. [Parameter()] [bool] $IncrementalPrerelease, @@ -31,23 +31,13 @@ BeforeAll { # The prefix put in front of the version, for example 'v'. [Parameter()] - [string] $VersionPrefix = 'v', - - # The release type resolved from the pull request labels. - [Parameter()] - [string] $ReleaseType = 'Release' + [string] $VersionPrefix = 'v' ) [PSCustomObject]@{ - AutoPatching = $AutoPatching IncrementalPrerelease = $IncrementalPrerelease DatePrereleaseFormat = $DatePrereleaseFormat VersionPrefix = $VersionPrefix - ReleaseType = $ReleaseType - IgnoreLabels = @('NoRelease') - MajorLabels = @('major') - MinorLabels = @('minor') - PatchLabels = @('patch') } } @@ -86,6 +76,59 @@ BeforeAll { PatchRelease = $Bump -eq 'Patch' HasVersionBump = $Bump -ne 'None' PrereleaseName = $PrereleaseName + SkipRelease = $Bump -eq 'None' + Bump = $Bump + } + } + + function Get-TestReleaseContext { + <# + .SYNOPSIS + Builds a normalized release context for decision tests. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [ValidateSet('PullRequest', 'Stable', 'Cleanup', 'Validation')] + [string] $Type = 'Stable', + + [Parameter()] + [AllowEmptyCollection()] + [AllowNull()] + [string[]] $Labels = @('release:patch'), + + [Parameter()] + [ValidateSet('Labels', 'Input', 'None')] + [string] $DecisionSource = 'Labels', + + [Parameter()] + [string] $ExplicitDecision = '', + + [Parameter()] + [bool] $RequiresDecision = $true, + + [Parameter()] + [bool] $CanPublish = $true, + + [Parameter()] + [bool] $HasImportantChanges = $true, + + [Parameter()] + [string] $HeadRef = 'feature-release' + ) + + [PSCustomObject]@{ + Type = $Type + DecisionSource = $DecisionSource + RequiresDecision = $RequiresDecision + CanPublish = $CanPublish + HasImportantChanges = $HasImportantChanges + Number = 42 + HeadRef = $HeadRef + Labels = $Labels + ExplicitDecision = $ExplicitDecision + IsDirectRelease = $DecisionSource -eq 'Input' } } } @@ -408,7 +451,7 @@ Describe 'Resolve-PSModuleVersion' { $params = @{ LatestVersion = New-PSSemVer -Version '0.0.0' Decision = Get-TestDecision -Bump 'Patch' -CreatePrerelease $true -PrereleaseName 'mybranch' - Configuration = Get-TestConfiguration -ReleaseType 'Prerelease' + Configuration = Get-TestConfiguration ModuleName = 'MyBrandNewModule' Releases = @() } @@ -486,82 +529,395 @@ Describe 'Resolve-PSModuleVersion' { } } - Describe 'Get-GitHubPullRequest' { - It 'uses the normalized pull request from a default-branch push' { + Describe 'Get-ReleaseContext' { + It 'uses merged pull request labels for an associated default-branch push' { $settings = @{ - Context = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'push' + EventAction = '' IsPushToDefaultBranch = $true DefaultBranch = 'main' PullRequest = @{ Number = 390 HeadRef = 'feature/push-release' - Labels = @('minor') + Labels = @('release:minor') } } } | ConvertTo-Json -Depth 5 - $result = Get-GitHubPullRequest -SettingsJson $settings + $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:skip' + $result.Type | Should -BeExactly 'Stable' + $result.DecisionSource | Should -BeExactly 'Labels' $result.Number | Should -Be 390 - $result.HeadRef | Should -Be 'feature/push-release' - $result.Labels | Should -Be @('minor') + $result.Labels | Should -Be @('release:minor') + $result.IsDirectRelease | Should -BeFalse } - It 'creates default patch context for a direct default-branch push' { + It 'uses the explicit input for an unassociated default-branch push' { $settings = @{ - Context = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'push' + EventAction = '' IsPushToDefaultBranch = $true DefaultBranch = 'main' PullRequest = $null } } | ConvertTo-Json -Depth 5 - $result = Get-GitHubPullRequest -SettingsJson $settings + $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:major' - $result.Number | Should -BeNullOrEmpty - $result.HeadRef | Should -Be 'main' - $result.Labels | Should -BeNullOrEmpty + $result.Type | Should -BeExactly 'Stable' + $result.DecisionSource | Should -BeExactly 'Input' + $result.ExplicitDecision | Should -BeExactly 'release:major' $result.IsDirectRelease | Should -BeTrue } + + It 'uses the explicit input for a default-branch workflow dispatch' { + $settings = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'workflow_dispatch' + EventAction = '' + IsManualDispatchToDefaultBranch = $true + DefaultBranch = 'main' + PullRequest = $null + } + } | ConvertTo-Json -Depth 5 + + $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:patch' + + $result.Type | Should -BeExactly 'Stable' + $result.DecisionSource | Should -BeExactly 'Input' + $result.CanPublish | Should -BeTrue + } + + It 'creates a cleanup context for a closed pull request' { + $settings = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'pull_request' + EventAction = 'closed' + DefaultBranch = 'main' + PullRequest = @{ + Number = 390 + HeadRef = 'feature/closed' + Labels = @('release:major', 'release:minor') + } + } + } | ConvertTo-Json -Depth 5 + + $result = Get-ReleaseContext -SettingsJson $settings + + $result.Type | Should -BeExactly 'Cleanup' + $result.RequiresDecision | Should -BeFalse + $result.Labels | Should -BeNullOrEmpty + } + + It 'creates a validation context for a scheduled run' { + $settings = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'schedule' + EventAction = '' + DefaultBranch = 'main' + PullRequest = $null + } + } | ConvertTo-Json -Depth 5 + + $result = Get-ReleaseContext -SettingsJson $settings + + $result.Type | Should -BeExactly 'Validation' + $result.RequiresDecision | Should -BeFalse + $result.CanPublish | Should -BeFalse + } + + It 'does not authorize unsupported events that carry pull request data' { + $settings = @{ + HasImportantChanges = $true + Context = @{ + EventName = 'pull_request_target' + EventAction = 'opened' + DefaultBranch = 'main' + PullRequest = @{ + Number = 390 + HeadRef = 'untrusted/fork' + Labels = @('release:patch', 'release:pre-release') + } + } + } | ConvertTo-Json -Depth 5 + + $result = Get-ReleaseContext -SettingsJson $settings + + $result.Type | Should -BeExactly 'Validation' + $result.RequiresDecision | Should -BeFalse + $result.CanPublish | Should -BeFalse + $result.Labels | Should -BeNullOrEmpty + } + + It 'fails closed when a push lacks normalized default-branch context' { + $previousEventName = $env:GITHUB_EVENT_NAME + $previousEventJson = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson + try { + $env:GITHUB_EVENT_NAME = 'push' + $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = '{}' + $settings = @{ HasImportantChanges = $true } | ConvertTo-Json + + { Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:patch' } | + Should -Throw '*without normalized default-branch context*' + } finally { + $env:GITHUB_EVENT_NAME = $previousEventName + $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = $previousEventJson + } + } } Describe 'Resolve-ReleaseDecision' { - It 'uses the default patch bump for a direct stable release' { - $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -AutoPatching $false) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @(); IsDirectRelease = $true }) + It 'resolves a stable release' -ForEach @( + @{ Label = 'release:patch'; Bump = 'Patch' } + @{ Label = 'release:minor'; Bump = 'Minor' } + @{ Label = 'release:major'; Bump = 'Major' } + ) { + $context = Get-TestReleaseContext -Type Stable -Labels @($Label) + + $result = Resolve-ReleaseDecision -ReleaseContext $context + $result.Bump | Should -BeExactly $Bump $result.ShouldPublish | Should -BeTrue - $result.PatchRelease | Should -BeTrue + $result.CreateRelease | Should -BeTrue + $result.CreatePrerelease | Should -BeFalse } - 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 = @() }) + It 'resolves a pull request prerelease' -ForEach @( + @{ Label = 'release:patch'; Bump = 'Patch' } + @{ Label = 'release:minor'; Bump = 'Minor' } + @{ Label = 'release:major'; Bump = 'Major' } + ) { + $labels = @($Label, 'release:pre-release') + $context = Get-TestReleaseContext -Type PullRequest -Labels $labels + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly $Bump + $result.ShouldPublish | Should -BeTrue + $result.CreateRelease | Should -BeFalse + $result.CreatePrerelease | Should -BeTrue + } + It 'creates a non-publishing preview version for a bump-only pull request' { + $context = Get-TestReleaseContext -Type PullRequest -Labels @('release:minor') + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly 'Minor' $result.ShouldPublish | Should -BeFalse - $result.PatchRelease | Should -BeTrue + $result.CreatePrerelease | Should -BeTrue + } + + It 'publishes a merged pull request as stable when prerelease mode remains applied' { + $labels = @('release:minor', 'release:pre-release') + $context = Get-TestReleaseContext -Type Stable -Labels $labels + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.ShouldPublish | Should -BeTrue + $result.CreateRelease | Should -BeTrue + $result.CreatePrerelease | Should -BeFalse } - It 'does not validate cleanup-only pull request labels' { - $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -ReleaseType None) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @('NoRelease', 'patch') }) + It 'resolves release:skip without a version bump' -ForEach @( + @{ Type = 'PullRequest' } + @{ Type = 'Stable' } + ) { + $context = Get-TestReleaseContext -Type $Type -Labels @('release:skip') + + $result = Resolve-ReleaseDecision -ReleaseContext $context + $result.Bump | Should -BeExactly 'None' + $result.SkipRelease | Should -BeTrue $result.ShouldPublish | Should -BeFalse $result.HasVersionBump | Should -BeFalse } - It 'rejects multiple version labels' { - { - Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('major', 'patch') }) - } | Should -Throw '*Conflicting version labels*' + It 'ignores unrelated labels alongside one canonical decision' { + $labels = @('dependencies', 'Major', 'release:patch', 'release:unknown') + $context = Get-TestReleaseContext -Type Stable -Labels $labels + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly 'Patch' + $result.ShouldPublish | Should -BeTrue } - It 'rejects a NoRelease label combined with a version label' { - { - Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('NoRelease', 'patch') }) - } | Should -Throw '*ignore label cannot be combined*' + It 'validates but does not publish an unimportant canonical change' { + $contextParams = @{ + Type = 'Stable' + Labels = @('release:major') + HasImportantChanges = $false + } + $context = Get-TestReleaseContext @contextParams + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly 'Major' + $result.ShouldPublish | Should -BeFalse + } + + It 'rejects ' -ForEach @( + @{ + Name = 'an empty label set' + Labels = @() + Message = '*Release decision is missing*' + } + @{ + Name = 'legacy bare labels' + Labels = @('Major', 'Minor', 'Patch', 'Prerelease', 'NoRelease') + Message = '*Release decision is missing*' + } + @{ + Name = 'lowercase bare labels and aliases' + Labels = @('major', 'minor', 'patch', 'prerelease', 'breaking', 'feature', 'fix') + Message = '*Release decision is missing*' + } + @{ + Name = 'noncanonical casing' + Labels = @('Release:Patch') + Message = '*Release decision is missing*' + } + @{ + Name = 'prerelease without a bump' + Labels = @('release:pre-release') + Message = '*release:pre-release requires exactly one release bump label*' + } + @{ + Name = 'patch and minor' + Labels = @('release:patch', 'release:minor') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'minor and major' + Labels = @('release:minor', 'release:major') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'patch and major' + Labels = @('release:patch', 'release:major') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'all bumps' + Labels = @('release:patch', 'release:minor', 'release:major') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'skip and patch' + Labels = @('release:skip', 'release:patch') + Message = '*release:skip must not be combined*' + } + @{ + Name = 'skip and prerelease' + Labels = @('release:skip', 'release:pre-release') + Message = '*release:skip must not be combined*' + } + ) { + $context = Get-TestReleaseContext -Type PullRequest -Labels $Labels + + { Resolve-ReleaseDecision -ReleaseContext $context } | + Should -Throw $Message + } + + It 'resolves an explicit decision' -ForEach @( + @{ Decision = 'release:patch'; Bump = 'Patch' } + @{ Decision = 'release:minor'; Bump = 'Minor' } + @{ Decision = 'release:major'; Bump = 'Major' } + ) { + $contextParams = @{ + Type = 'Stable' + DecisionSource = 'Input' + ExplicitDecision = $Decision + Labels = @() + } + $context = Get-TestReleaseContext @contextParams + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly $Bump + $result.CreateRelease | Should -BeTrue + } + + It 'resolves explicit release:skip' { + $contextParams = @{ + Type = 'Stable' + DecisionSource = 'Input' + ExplicitDecision = 'release:skip' + Labels = @() + } + $context = Get-TestReleaseContext @contextParams + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.SkipRelease | Should -BeTrue + $result.ShouldPublish | Should -BeFalse + } + + It 'rejects the invalid explicit decision ' -ForEach @( + @{ Decision = ''; Message = '*Invalid or missing ReleaseDecision*' } + @{ Decision = 'release:pre-release'; Message = '*Invalid or missing ReleaseDecision*' } + @{ Decision = 'patch'; Message = '*Invalid or missing ReleaseDecision*' } + @{ Decision = 'Release:Patch'; Message = '*Invalid or missing ReleaseDecision*' } + ) { + $contextParams = @{ + Type = 'Stable' + DecisionSource = 'Input' + ExplicitDecision = $Decision + Labels = @() + } + $context = Get-TestReleaseContext @contextParams + + { Resolve-ReleaseDecision -ReleaseContext $context } | + Should -Throw $Message + } + + It 'does not let an explicit input override associated pull request labels' { + $context = Get-TestReleaseContext -Type Stable -Labels @('release:minor') + $context | Add-Member -MemberType NoteProperty ` + -Name ExplicitDecision -Value 'release:major' -Force + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.Bump | Should -BeExactly 'Minor' + } + + It 'bypasses a release decision for ' -ForEach @( + @{ Type = 'Cleanup' } + @{ Type = 'Validation' } + ) { + $contextParams = @{ + Type = $Type + DecisionSource = 'None' + RequiresDecision = $false + Labels = @('release:major', 'release:minor') + } + $context = Get-TestReleaseContext @contextParams + + $result = Resolve-ReleaseDecision -ReleaseContext $context + + $result.ShouldPublish | Should -BeFalse + $result.HasVersionBump | Should -BeFalse + } + + It 'rejects a pull request context without a head branch' { + $contextParams = @{ + Type = 'PullRequest' + Labels = @('release:patch') + HeadRef = '' + } + $context = Get-TestReleaseContext @contextParams + + { Resolve-ReleaseDecision -ReleaseContext $context } | + Should -Throw '*head branch name is missing*' } } } From 042e7b5e72813c2b9f6a17643e5693bf7f931de9 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:45:55 +0200 Subject: [PATCH 02/21] Require explicit non-PR release decisions --- .../Resolve-PSModuleVersion/action.yml | 1 - .github/workflows/Plan.yml | 21 +++++++++++++++++-- .github/workflows/Workflow-Test-Default.yml | 16 ++++++++++++++ .../workflows/Workflow-Test-WithManifest.yml | 16 ++++++++++++++ .github/workflows/workflow.yml | 5 +++++ 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.github/actions/Resolve-PSModuleVersion/action.yml b/.github/actions/Resolve-PSModuleVersion/action.yml index 54cbb8dc..64e3dbd5 100644 --- a/.github/actions/Resolve-PSModuleVersion/action.yml +++ b/.github/actions/Resolve-PSModuleVersion/action.yml @@ -35,7 +35,6 @@ inputs: ReleaseDecision: description: Explicit decision for a direct push or workflow dispatch - release:patch, release:minor, release:major, or release:skip. required: false - default: '' outputs: Version: diff --git a/.github/workflows/Plan.yml b/.github/workflows/Plan.yml index 0e0f1a7b..2b5c72c8 100644 --- a/.github/workflows/Plan.yml +++ b/.github/workflows/Plan.yml @@ -56,6 +56,10 @@ on: default: | ^src/ ^README\.md$ + ReleaseDecision: + type: string + description: Explicit decision for a direct push or workflow dispatch. + required: false outputs: Settings: @@ -120,6 +124,7 @@ jobs: Debug: ${{ inputs.Debug }} Verbose: ${{ inputs.Verbose }} WorkingDirectory: ${{ inputs.WorkingDirectory }} + ReleaseDecision: ${{ inputs.ReleaseDecision }} - name: Enrich-Settings # Merge the resolved version into the Settings object so all downstream jobs @@ -134,13 +139,25 @@ jobs: RELEASE_TYPE: ${{ steps.Resolve-Version.outputs.ReleaseType }} CREATE_RELEASE: ${{ steps.Resolve-Version.outputs.CreateRelease }} run: | + Import-Module -Name './_wf/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1' -Force + $settings = $env:SETTINGS | ConvertFrom-Json + $releaseType = if ([string]::IsNullOrEmpty($env:RELEASE_TYPE)) { 'None' } else { $env:RELEASE_TYPE } + $shouldPublish = $env:CREATE_RELEASE -eq 'true' + $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Resolution -Value ([pscustomobject]@{ Version = $env:VERSION Prerelease = $env:PRERELEASE FullVersion = $env:FULL_VERSION - ReleaseType = if ([string]::IsNullOrEmpty($env:RELEASE_TYPE)) { 'None' } else { $env:RELEASE_TYPE } - CreateRelease = $env:CREATE_RELEASE -eq 'true' + ReleaseType = $releaseType + CreateRelease = $shouldPublish }) -Force + $publishStateParams = @{ + Settings = $settings + ReleaseType = $releaseType + ShouldPublish = $shouldPublish + } + $settings = Resolve-PSModulePublishState @publishStateParams + $enriched = $settings | ConvertTo-Json -Depth 10 -Compress "Settings=$enriched" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 2bbae66e..b6c77e70 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -4,6 +4,16 @@ run-name: 'Workflow-Test [Default] - ${{ github.event_name }} [${{ github.event. on: workflow_dispatch: + inputs: + ReleaseDecision: + description: Explicit release decision for this manual run. + type: choice + required: true + options: + - release:skip + - release:patch + - release:minor + - release:major push: branches: - main @@ -62,6 +72,12 @@ jobs: ^tests/srcTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) + ReleaseDecision: >- + ${{ + github.event_name == 'workflow_dispatch' && inputs.ReleaseDecision || + github.event_name == 'push' && 'release:skip' || + '' + }} VerifyRootFunctionsIndexDefault: if: github.event.action != 'closed' diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 83829813..89ddc568 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -4,6 +4,16 @@ run-name: 'Workflow-Test [WithManifest] - ${{ github.event_name }} [${{ github.e on: workflow_dispatch: + inputs: + ReleaseDecision: + description: Explicit release decision for this manual run. + type: choice + required: true + options: + - release:skip + - release:patch + - release:minor + - release:major push: branches: - main @@ -62,6 +72,12 @@ jobs: ^tests/srcWithManifestTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) + ReleaseDecision: >- + ${{ + github.event_name == 'workflow_dispatch' && inputs.ReleaseDecision || + github.event_name == 'push' && 'release:skip' || + '' + }} VerifyRootFunctionsIndexWithManifest: if: github.event.action != 'closed' diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index db9a0999..73614c89 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -67,6 +67,10 @@ on: default: | ^src/ ^README\.md$ + ReleaseDecision: + type: string + description: Explicit decision for a direct push or workflow dispatch. + required: false permissions: contents: read # to checkout the repo @@ -96,6 +100,7 @@ jobs: Version: ${{ inputs.Version }} WorkingDirectory: ${{ inputs.WorkingDirectory }} ImportantFilePatterns: ${{ inputs.ImportantFilePatterns }} + ReleaseDecision: ${{ inputs.ReleaseDecision }} # Runs on: # - ✅ Open/Updated PR - Lints code changes in active PRs From 6586030ea48d1c6ecd0b39d2b4d7761ea9bcdcc4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 00:09:05 +0200 Subject: [PATCH 03/21] Revert "Require explicit non-PR release decisions" This reverts commit 042e7b5e72813c2b9f6a17643e5693bf7f931de9. --- .../Resolve-PSModuleVersion/action.yml | 1 + .github/workflows/Plan.yml | 21 ++----------------- .github/workflows/Workflow-Test-Default.yml | 16 -------------- .../workflows/Workflow-Test-WithManifest.yml | 16 -------------- .github/workflows/workflow.yml | 5 ----- 5 files changed, 3 insertions(+), 56 deletions(-) diff --git a/.github/actions/Resolve-PSModuleVersion/action.yml b/.github/actions/Resolve-PSModuleVersion/action.yml index 64e3dbd5..54cbb8dc 100644 --- a/.github/actions/Resolve-PSModuleVersion/action.yml +++ b/.github/actions/Resolve-PSModuleVersion/action.yml @@ -35,6 +35,7 @@ inputs: ReleaseDecision: description: Explicit decision for a direct push or workflow dispatch - release:patch, release:minor, release:major, or release:skip. required: false + default: '' outputs: Version: diff --git a/.github/workflows/Plan.yml b/.github/workflows/Plan.yml index 2b5c72c8..0e0f1a7b 100644 --- a/.github/workflows/Plan.yml +++ b/.github/workflows/Plan.yml @@ -56,10 +56,6 @@ on: default: | ^src/ ^README\.md$ - ReleaseDecision: - type: string - description: Explicit decision for a direct push or workflow dispatch. - required: false outputs: Settings: @@ -124,7 +120,6 @@ jobs: Debug: ${{ inputs.Debug }} Verbose: ${{ inputs.Verbose }} WorkingDirectory: ${{ inputs.WorkingDirectory }} - ReleaseDecision: ${{ inputs.ReleaseDecision }} - name: Enrich-Settings # Merge the resolved version into the Settings object so all downstream jobs @@ -139,25 +134,13 @@ jobs: RELEASE_TYPE: ${{ steps.Resolve-Version.outputs.ReleaseType }} CREATE_RELEASE: ${{ steps.Resolve-Version.outputs.CreateRelease }} run: | - Import-Module -Name './_wf/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1' -Force - $settings = $env:SETTINGS | ConvertFrom-Json - $releaseType = if ([string]::IsNullOrEmpty($env:RELEASE_TYPE)) { 'None' } else { $env:RELEASE_TYPE } - $shouldPublish = $env:CREATE_RELEASE -eq 'true' - $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Resolution -Value ([pscustomobject]@{ Version = $env:VERSION Prerelease = $env:PRERELEASE FullVersion = $env:FULL_VERSION - ReleaseType = $releaseType - CreateRelease = $shouldPublish + ReleaseType = if ([string]::IsNullOrEmpty($env:RELEASE_TYPE)) { 'None' } else { $env:RELEASE_TYPE } + CreateRelease = $env:CREATE_RELEASE -eq 'true' }) -Force - $publishStateParams = @{ - Settings = $settings - ReleaseType = $releaseType - ShouldPublish = $shouldPublish - } - $settings = Resolve-PSModulePublishState @publishStateParams - $enriched = $settings | ConvertTo-Json -Depth 10 -Compress "Settings=$enriched" >> $env:GITHUB_OUTPUT diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index b6c77e70..2bbae66e 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -4,16 +4,6 @@ run-name: 'Workflow-Test [Default] - ${{ github.event_name }} [${{ github.event. on: workflow_dispatch: - inputs: - ReleaseDecision: - description: Explicit release decision for this manual run. - type: choice - required: true - options: - - release:skip - - release:patch - - release:minor - - release:major push: branches: - main @@ -72,12 +62,6 @@ jobs: ^tests/srcTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) - ReleaseDecision: >- - ${{ - github.event_name == 'workflow_dispatch' && inputs.ReleaseDecision || - github.event_name == 'push' && 'release:skip' || - '' - }} VerifyRootFunctionsIndexDefault: if: github.event.action != 'closed' diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 89ddc568..83829813 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -4,16 +4,6 @@ run-name: 'Workflow-Test [WithManifest] - ${{ github.event_name }} [${{ github.e on: workflow_dispatch: - inputs: - ReleaseDecision: - description: Explicit release decision for this manual run. - type: choice - required: true - options: - - release:skip - - release:patch - - release:minor - - release:major push: branches: - main @@ -72,12 +62,6 @@ jobs: ^tests/srcWithManifestTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) - ReleaseDecision: >- - ${{ - github.event_name == 'workflow_dispatch' && inputs.ReleaseDecision || - github.event_name == 'push' && 'release:skip' || - '' - }} VerifyRootFunctionsIndexWithManifest: if: github.event.action != 'closed' diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 73614c89..db9a0999 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -67,10 +67,6 @@ on: default: | ^src/ ^README\.md$ - ReleaseDecision: - type: string - description: Explicit decision for a direct push or workflow dispatch. - required: false permissions: contents: read # to checkout the repo @@ -100,7 +96,6 @@ jobs: Version: ${{ inputs.Version }} WorkingDirectory: ${{ inputs.WorkingDirectory }} ImportantFilePatterns: ${{ inputs.ImportantFilePatterns }} - ReleaseDecision: ${{ inputs.ReleaseDecision }} # Runs on: # - ✅ Open/Updated PR - Lints code changes in active PRs From d5b7c726e01adf44165522f3644248e321359ebb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 00:09:05 +0200 Subject: [PATCH 04/21] Revert "Enforce namespaced module release decisions" This reverts commit 4fb660b58e0a12b742646b35832e8ea1e3a549a8. --- .../src/Get-PSModuleSettings.Helpers.psm1 | 109 +---- .../src/Settings.schema.json | 24 + .../actions/Get-PSModuleSettings/src/main.ps1 | 27 +- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 159 +----- .../Resolve-PSModuleVersion/action.yml | 5 - .../src/Resolve-PSModuleVersion.Helpers.psm1 | 344 +++++-------- .../Resolve-PSModuleVersion/src/main.ps1 | 22 +- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 456 ++---------------- 8 files changed, 254 insertions(+), 892 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 79f33f9a..ef5aef29 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -28,7 +28,10 @@ function Resolve-WorkflowEventRouting { [bool] $IsManualDispatchToDefaultBranch, [Parameter()] - [bool] $HasImportantChanges + [bool] $HasImportantChanges, + + [Parameter()] + [bool] $HasPrereleaseLabel ) $isPR = $EventName -eq 'pull_request' @@ -40,6 +43,7 @@ function Resolve-WorkflowEventRouting { $isClosedPR = $isPR -and $EventAction -eq 'closed' $isAbandonedPR = $isClosedPR -and -not $PullRequestIsMerged $isMergedPR = $isClosedPR -and $PullRequestIsMerged + $shouldPrerelease = $isOpenOrLabeledPR -and $HasPrereleaseLabel -and $HasImportantChanges $shouldRelease = ( ($IsPushToDefaultBranch -or $IsManualDispatchToDefaultBranch) -and $HasImportantChanges @@ -57,8 +61,11 @@ function Resolve-WorkflowEventRouting { IsTargetDefaultBranch = $IsTargetDefaultBranch IsPushToDefaultBranch = $IsPushToDefaultBranch IsManualDispatchToDefaultBranch = $IsManualDispatchToDefaultBranch + ShouldPrerelease = $shouldPrerelease ReleaseType = if ($shouldRelease) { 'Release' + } elseif ($shouldPrerelease) { + 'Prerelease' } else { 'None' } @@ -142,103 +149,3 @@ function Get-FilesFromGitHubComparison { $files | Select-Object -ExpandProperty filename } - -function Get-UnsupportedPSModuleReleaseSetting { - <# - .SYNOPSIS - Returns release settings removed from the Process-PSModule v9 contract. - - .DESCRIPTION - Inspects a publish-module settings object and returns any setting names that - configured automatic patching or custom release-label aliases before v9. - - .OUTPUTS - System.String - - .EXAMPLE - Get-UnsupportedPSModuleReleaseSetting -PublishModule $settings.Publish.Module - #> - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter()] - [AllowNull()] - [object] $PublishModule - ) - - if ($null -eq $PublishModule) { - return - } - - $settingNames = if ($PublishModule -is [System.Collections.IDictionary]) { - @($PublishModule.Keys) - } else { - @($PublishModule.PSObject.Properties.Name) - } - $unsupportedSettingNames = @( - 'AutoPatching' - 'MajorLabels' - 'MinorLabels' - 'PatchLabels' - 'PrereleaseLabels' - 'IgnoreLabels' - ) - - foreach ($settingName in $unsupportedSettingNames) { - if ($settingNames -contains $settingName) { - $settingName - } - } -} - -function Resolve-PSModulePublishState { - <# - .SYNOPSIS - Applies the resolved release decision to module and site publication state. - - .DESCRIPTION - Updates the runtime settings after Resolve-PSModuleVersion has decided - whether this run publishes a stable release, prerelease, or nothing. - Closed pull requests retain their cleanup-only path. - - .OUTPUTS - System.Management.Automation.PSCustomObject - - .EXAMPLE - $params = @{ - Settings = $settings - ReleaseType = 'None' - ShouldPublish = $false - } - Resolve-PSModulePublishState @params - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param( - [Parameter(Mandatory)] - [PSCustomObject] $Settings, - - [Parameter(Mandatory)] - [ValidateSet('Release', 'Prerelease', 'None')] - [string] $ReleaseType, - - [Parameter(Mandatory)] - [bool] $ShouldPublish - ) - - $isCleanupOnly = ( - $Settings.Context.EventName -eq 'pull_request' -and - $Settings.Context.EventAction -eq 'closed' - ) - $cleanupEnabled = $isCleanupOnly -and [bool]$Settings.Publish.Module.AutoCleanup - $moduleEnabled = $ShouldPublish -or $cleanupEnabled - $siteDesired = $ReleaseType -eq 'Release' - - $Settings.Publish.Module.ReleaseType = $ReleaseType - $Settings.Publish.Module.Desired = $moduleEnabled - $Settings.Publish.Module.Enabled = $moduleEnabled - $Settings.Publish.Site.Desired = $siteDesired - $Settings.Publish.Site.Enabled = $siteDesired -and -not [bool]$Settings.Publish.Site.Skip - - $Settings -} diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index f21d5a18..a51b90ac 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -138,6 +138,10 @@ "type": "boolean", "description": "When enabled (default: true), automatically cleans up old prerelease tags when merging to main or when a PR is abandoned" }, + "AutoPatching": { + "type": "boolean", + "description": "Automatically apply patches" + }, "IncrementalPrerelease": { "type": "boolean", "description": "Use incremental prerelease versioning" @@ -150,6 +154,26 @@ "type": "string", "description": "Prefix for version tags" }, + "MajorLabels": { + "type": "string", + "description": "Comma-separated labels that trigger major version bump" + }, + "MinorLabels": { + "type": "string", + "description": "Comma-separated labels that trigger minor version bump" + }, + "PatchLabels": { + "type": "string", + "description": "Comma-separated labels that trigger patch version bump" + }, + "IgnoreLabels": { + "type": "string", + "description": "Comma-separated labels that prevent release" + }, + "PrereleaseLabels": { + "type": "string", + "description": "Comma-separated labels that trigger a prerelease" + }, "UsePRTitleAsReleaseName": { "type": "boolean", "description": "Use pull request title as the GitHub release name" diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 273b8ee8..da1ad72b 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -73,17 +73,6 @@ if (![string]::IsNullOrEmpty($settingsPath) -and (Test-Path -Path $settingsPath) $settings = @{} } -$unsupportedReleaseSettings = @( - Get-UnsupportedPSModuleReleaseSetting -PublishModule $settings.Publish.Module -) -if ($unsupportedReleaseSettings.Count -gt 0) { - throw ( - "Unsupported Process-PSModule v9 release settings: [$($unsupportedReleaseSettings -join ', ')]. " + - 'Remove these settings. Release decisions now use only release:patch, release:minor, ' + - 'release:major, release:pre-release, and release:skip.' - ) -} - LogGroup 'Name' { [pscustomobject]@{ InputName = $name @@ -201,9 +190,15 @@ $settings = [pscustomobject]@{ Module = [pscustomobject]@{ Skip = $settings.Publish.Module.Skip ?? $false AutoCleanup = $settings.Publish.Module.AutoCleanup ?? $true + AutoPatching = $settings.Publish.Module.AutoPatching ?? $true IncrementalPrerelease = $settings.Publish.Module.IncrementalPrerelease ?? $true DatePrereleaseFormat = $settings.Publish.Module.DatePrereleaseFormat ?? '' VersionPrefix = $settings.Publish.Module.VersionPrefix ?? 'v' + MajorLabels = $settings.Publish.Module.MajorLabels ?? 'major, breaking' + MinorLabels = $settings.Publish.Module.MinorLabels ?? 'minor, feature' + PatchLabels = $settings.Publish.Module.PatchLabels ?? 'patch, fix' + IgnoreLabels = $settings.Publish.Module.IgnoreLabels ?? 'NoRelease' + PrereleaseLabels = $settings.Publish.Module.PrereleaseLabels ?? 'prerelease' UsePRTitleAsReleaseName = $settings.Publish.Module.UsePRTitleAsReleaseName ?? $false UsePRBodyAsReleaseNotes = $settings.Publish.Module.UsePRBodyAsReleaseNotes ?? $true UsePRTitleAsNotesHeading = $settings.Publish.Module.UsePRTitleAsNotesHeading ?? $true @@ -324,6 +319,11 @@ LogGroup 'Calculate Job Run Conditions:' { AssociatedPullRequest = $pullRequestContext.Number } | Format-List | Out-String + # Check if a prerelease label exists on the PR + $prereleaseLabels = $settings.Publish.Module.PrereleaseLabels -split ',' | ForEach-Object { $_.Trim() } + $prLabels = @($pullRequestContext.Labels) + $hasPrereleaseLabel = ($prLabels | Where-Object { $prereleaseLabels -contains $_ }).Count -gt 0 + # Check if important files have changed in the PR # Important files are determined by the configured ImportantFilePatterns setting $hasImportantChanges = $false @@ -449,7 +449,8 @@ If you believe this is incorrect, please verify that your changes are in the cor -IsTargetDefaultBranch $isTargetDefaultBranch ` -IsPushToDefaultBranch $isPushToDefaultBranch ` -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` - -HasImportantChanges $hasImportantChanges + -HasImportantChanges $hasImportantChanges ` + -HasPrereleaseLabel $hasPrereleaseLabel $releaseType = $routing.ReleaseType [pscustomobject]@{ @@ -463,6 +464,8 @@ If you believe this is incorrect, please verify that your changes are in the cor isManualDispatch = $routing.IsManualDispatch isPushToDefaultBranch = $routing.IsPushToDefaultBranch isTargetDefaultBranch = $routing.IsTargetDefaultBranch + hasPrereleaseLabel = $hasPrereleaseLabel + shouldPrerelease = $routing.ShouldPrerelease ReleaseType = $releaseType HasImportantChanges = $hasImportantChanges } | Format-List | Out-String 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 3d6623b2..f7d1fa8e 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -1,10 +1,3 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Variables are assigned in BeforeEach and used inside It blocks.' -)] -[CmdletBinding()] -param() - BeforeAll { Import-Module "$PSScriptRoot/../src/Get-PSModuleSettings.Helpers.psm1" -Force } @@ -49,13 +42,14 @@ Describe 'Resolve-WorkflowEventRouting' { $result.ReleaseType | Should -Be 'Release' } - It 'leaves open-PR publication for the version resolver' { + It 'routes a labeled open PR with important changes to a prerelease' { $result = Resolve-WorkflowEventRouting -EventName pull_request ` -EventAction labeled ` -IsTargetDefaultBranch $true ` - -HasImportantChanges $true + -HasImportantChanges $true ` + -HasPrereleaseLabel $true - $result.ReleaseType | Should -Be 'None' + $result.ReleaseType | Should -Be 'Prerelease' $result.ShouldRunBuildTest | Should -BeTrue } @@ -77,7 +71,8 @@ Describe 'Resolve-WorkflowEventRouting' { -EventAction labeled ` -PullRequestIsClosed $true ` -IsTargetDefaultBranch $true ` - -HasImportantChanges $true + -HasImportantChanges $true ` + -HasPrereleaseLabel $true $result.ReleaseType | Should -Be 'None' $result.IsOpenOrUpdatedPR | Should -BeFalse @@ -87,148 +82,6 @@ Describe 'Resolve-WorkflowEventRouting' { } } -Describe 'Get-UnsupportedPSModuleReleaseSetting' { - It 'returns no settings when the publish configuration uses the v9 contract' { - $publishModule = [pscustomobject]@{ - AutoCleanup = $true - IncrementalPrerelease = $true - VersionPrefix = 'v' - } - - @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule).Count | - Should -Be 0 - } - - It 'returns the removed setting from a PSCustomObject' -ForEach @( - @{ Name = 'AutoPatching' } - @{ Name = 'MajorLabels' } - @{ Name = 'MinorLabels' } - @{ Name = 'PatchLabels' } - @{ Name = 'PrereleaseLabels' } - @{ Name = 'IgnoreLabels' } - ) { - $publishModule = [pscustomobject]@{} - $publishModule | Add-Member -MemberType NoteProperty -Name $Name -Value 'legacy' - - Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule | - Should -BeExactly $Name - } - - It 'returns every removed setting from a dictionary in migration order' { - $publishModule = @{ - IgnoreLabels = 'NoRelease' - AutoPatching = $true - PrereleaseLabels = 'Prerelease' - MajorLabels = 'Major' - MinorLabels = 'Minor' - PatchLabels = 'Patch' - } - - @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule) | - Should -Be @( - 'AutoPatching' - 'MajorLabels' - 'MinorLabels' - 'PatchLabels' - 'PrereleaseLabels' - 'IgnoreLabels' - ) - } - - It 'rejects removed setting names case-insensitively' { - $publishModule = @{ autopatching = $true } - - Get-UnsupportedPSModuleReleaseSetting -PublishModule $publishModule | - Should -BeExactly 'AutoPatching' - } - - It 'accepts a null publish configuration' { - @(Get-UnsupportedPSModuleReleaseSetting -PublishModule $null).Count | - Should -Be 0 - } -} - -Describe 'Resolve-PSModulePublishState' { - BeforeEach { - $settings = [pscustomobject]@{ - Context = [pscustomobject]@{ - EventName = 'push' - EventAction = '' - } - Publish = [pscustomobject]@{ - Module = [pscustomobject]@{ - AutoCleanup = $true - ReleaseType = 'Release' - Desired = $true - Enabled = $true - } - Site = [pscustomobject]@{ - Skip = $false - Desired = $true - Enabled = $true - } - } - } - } - - It 'enables module and site publication for a stable release' { - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType Release -ShouldPublish $true - - $result.Publish.Module.ReleaseType | Should -BeExactly 'Release' - $result.Publish.Module.Enabled | Should -BeTrue - $result.Publish.Site.Enabled | Should -BeTrue - } - - It 'enables only module publication for a prerelease' { - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType Prerelease -ShouldPublish $true - - $result.Publish.Module.Enabled | Should -BeTrue - $result.Publish.Site.Enabled | Should -BeFalse - } - - It 'disables all publication for release:skip' { - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType None -ShouldPublish $false - - $result.Publish.Module.Enabled | Should -BeFalse - $result.Publish.Site.Enabled | Should -BeFalse - } - - It 'respects the site publication setting for a stable release' { - $settings.Publish.Site.Skip = $true - - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType Release -ShouldPublish $true - - $result.Publish.Module.Enabled | Should -BeTrue - $result.Publish.Site.Enabled | Should -BeFalse - } - - It 'retains closed-pull-request cleanup without enabling site publication' { - $settings.Context.EventName = 'pull_request' - $settings.Context.EventAction = 'closed' - - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType None -ShouldPublish $false - - $result.Publish.Module.Enabled | Should -BeTrue - $result.Publish.Site.Enabled | Should -BeFalse - } - - It 'disables closed-pull-request cleanup when AutoCleanup is disabled' { - $settings.Context.EventName = 'pull_request' - $settings.Context.EventAction = 'closed' - $settings.Publish.Module.AutoCleanup = $false - - $result = Resolve-PSModulePublishState -Settings $settings ` - -ReleaseType None -ShouldPublish $false - - $result.Publish.Module.Enabled | Should -BeFalse - } -} - Describe 'Select-PullRequestForPush' { It 'selects the merged PR whose merge commit matches the pushed commit' { $pullRequests = @( diff --git a/.github/actions/Resolve-PSModuleVersion/action.yml b/.github/actions/Resolve-PSModuleVersion/action.yml index 54cbb8dc..5192f28f 100644 --- a/.github/actions/Resolve-PSModuleVersion/action.yml +++ b/.github/actions/Resolve-PSModuleVersion/action.yml @@ -32,10 +32,6 @@ inputs: description: GitHub event payload as a JSON string. When set, overrides reading from the event file. Use for testing. required: false default: '' - ReleaseDecision: - description: Explicit decision for a direct push or workflow dispatch - release:patch, release:minor, release:major, or release:skip. - required: false - default: '' outputs: Version: @@ -73,6 +69,5 @@ runs: PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_Settings: ${{ inputs.Settings }} PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_Name: ${{ inputs.Name }} PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson: ${{ inputs.EventJson }} - PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_ReleaseDecision: ${{ inputs.ReleaseDecision }} GITHUB_EVENT_PATH: ${{ inputs.EventPath || github.event_path }} run: ${{ github.action_path }}/src/main.ps1 diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index 23aee4c4..ab9b8560 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -1,4 +1,25 @@ -function Read-ActionInput { +function Split-CommaSeparatedList { + <# + .SYNOPSIS + Splits a comma-separated string into a trimmed, non-empty array. + + .EXAMPLE + Split-CommaSeparatedList -Value 'Major, Minor, Patch' + + Returns @('Major', 'Minor', 'Patch'). + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + # The comma-separated string to split. + [Parameter()] + [string] $Value + ) + + ($Value -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } +} + +function Read-ActionInput { <# .SYNOPSIS Reads and validates action inputs from environment variables. @@ -8,7 +29,7 @@ Falls back to the repository name when the module name input is not provided. .OUTPUTS - PSCustomObject with Name, SettingsJson, and ReleaseDecision properties. + PSCustomObject with Name and SettingsJson properties. .EXAMPLE $actionInput = Read-ActionInput @@ -33,9 +54,8 @@ } [PSCustomObject]@{ - Name = $name - SettingsJson = $settingsJson - ReleaseDecision = [string]$env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_ReleaseDecision + Name = $name + SettingsJson = $settingsJson } } } @@ -46,7 +66,8 @@ function Get-PublishConfiguration { Parses the settings JSON into a publish configuration object. .DESCRIPTION - Extracts publish module settings used to format versions and prereleases. + Extracts publish module settings including auto-patching flags, version prefix, + release type, and label classification arrays. .OUTPUTS PSCustomObject with publish configuration properties. @@ -70,16 +91,28 @@ function Get-PublishConfiguration { $publishModule = $settings.Publish.Module $config = [PSCustomObject]@{ + AutoPatching = [bool]$publishModule.AutoPatching IncrementalPrerelease = [bool]$publishModule.IncrementalPrerelease DatePrereleaseFormat = [string]$publishModule.DatePrereleaseFormat VersionPrefix = [string]$publishModule.VersionPrefix + ReleaseType = [string]$publishModule.ReleaseType + IgnoreLabels = Split-CommaSeparatedList ([string]$publishModule.IgnoreLabels) + MajorLabels = Split-CommaSeparatedList ([string]$publishModule.MajorLabels) + MinorLabels = Split-CommaSeparatedList ([string]$publishModule.MinorLabels) + PatchLabels = Split-CommaSeparatedList ([string]$publishModule.PatchLabels) } Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ + AutoPatching = $config.AutoPatching IncrementalPrerelease = $config.IncrementalPrerelease DatePrereleaseFormat = $config.DatePrereleaseFormat VersionPrefix = $config.VersionPrefix + ReleaseType = $config.ReleaseType + IgnoreLabels = $config.IgnoreLabels -join ', ' + MajorLabels = $config.MajorLabels -join ', ' + MinorLabels = $config.MinorLabels -join ', ' + PatchLabels = $config.PatchLabels -join ', ' } | Format-List | Out-String) Write-Host '-------------------------------------------------' @@ -87,25 +120,25 @@ function Get-PublishConfiguration { } } -function Get-ReleaseContext { +function Get-GitHubPullRequest { <# .SYNOPSIS - Reads normalized release context from settings, with event-payload fallback. + Reads normalized pull-request context from settings, with event-payload fallback. .DESCRIPTION - The settings action resolves a pull request associated with a default-branch - push before this action runs. Pull requests use canonical labels, while a - release-capable event without a pull request uses the explicit action input. + The settings action resolves the pull request associated with a default-branch push + before this action runs. When no pull request exists, a direct push or manual + dispatch on the default branch still receives release context so it resolves the + default patch bump. .OUTPUTS - PSCustomObject describing whether the run is a pull request, stable release, - cleanup, or validation context. + PSCustomObject with pull-request metadata, or a default-branch direct-release + context with no pull-request number. .EXAMPLE - $releaseContext = Get-ReleaseContext -SettingsJson $actionInput.SettingsJson ` - -ReleaseDecision $actionInput.ReleaseDecision + $pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'SettingsJson', Justification = 'Parameter is used inside a LogGroup script block.')] [CmdletBinding()] [OutputType([PSCustomObject])] @@ -113,12 +146,7 @@ function Get-ReleaseContext { # The complete settings object, including normalized workflow context. [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] - [string] $SettingsJson, - - # Explicit canonical decision for a release-capable event without a pull request. - [Parameter()] - [AllowEmptyString()] - [string] $ReleaseDecision = '' + [string] $SettingsJson ) LogGroup 'Event information' { @@ -126,91 +154,24 @@ function Get-ReleaseContext { $context = $settings.Context if ($context) { $contextPullRequest = $context.PullRequest - $eventName = [string]$context.EventName - $eventAction = [string]$context.EventAction - $isCleanupOnly = $eventName -eq 'pull_request' -and $eventAction -eq 'closed' - - if ($isCleanupOnly) { - Write-Host 'Using cleanup-only closed pull request context.' - return [PSCustomObject]@{ - Type = 'Cleanup' - DecisionSource = 'None' - RequiresDecision = $false - CanPublish = $false - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $contextPullRequest.Number - HeadRef = [string]$contextPullRequest.HeadRef - Labels = @() - IsDirectRelease = $false - } - } - if ($contextPullRequest) { - if ($eventName -notin @('pull_request', 'push')) { - Write-Host "Ignoring pull request data in unsupported [$eventName] release context." - return [PSCustomObject]@{ - Type = 'Validation' - DecisionSource = 'None' - RequiresDecision = $false - CanPublish = $false - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $contextPullRequest.Number - HeadRef = [string]$contextPullRequest.HeadRef - Labels = @() - IsDirectRelease = $false - } - } - Write-Host "Using normalized pull request context for #$($contextPullRequest.Number)." - $isStableContext = $eventName -eq 'push' return [PSCustomObject]@{ - Type = if ($isStableContext) { 'Stable' } else { 'PullRequest' } - DecisionSource = 'Labels' - RequiresDecision = $true - CanPublish = if ($isStableContext) { - [bool]$context.IsPushToDefaultBranch - } else { - $true - } - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $contextPullRequest.Number - HeadRef = [string]$contextPullRequest.HeadRef - Labels = @($contextPullRequest.Labels) - IsDirectRelease = $false + Number = $contextPullRequest.Number + HeadRef = $contextPullRequest.HeadRef + Labels = @($contextPullRequest.Labels) } } - if ($eventName -in @('push', 'workflow_dispatch')) { - Write-Host "Using explicit [$eventName] release context." + if ($context.IsPushToDefaultBranch -or $context.IsManualDispatchToDefaultBranch) { + Write-Host 'Using direct default-branch release context with the default patch bump.' return [PSCustomObject]@{ - Type = 'Stable' - DecisionSource = 'Input' - RequiresDecision = $true - CanPublish = [bool]( - $context.IsPushToDefaultBranch -or - $context.IsManualDispatchToDefaultBranch - ) - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $null - HeadRef = [string]$context.DefaultBranch - Labels = @() - ExplicitDecision = $ReleaseDecision - IsDirectRelease = $true + Number = $null + HeadRef = $context.DefaultBranch + Labels = @() + IsDirectRelease = $true } } - - Write-Host "Using validation-only [$eventName] context." - return [PSCustomObject]@{ - Type = 'Validation' - DecisionSource = 'None' - RequiresDecision = $false - CanPublish = $false - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $null - HeadRef = [string]$context.DefaultBranch - Labels = @() - IsDirectRelease = $false - } } $eventJsonInput = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson @@ -222,23 +183,8 @@ function Get-ReleaseContext { $pr = $githubEvent.pull_request if (-not $pr) { - $eventName = [string]$env:GITHUB_EVENT_NAME - if ($eventName -in @('push', 'workflow_dispatch')) { - throw "Cannot authorize [$eventName] publication without normalized default-branch context." - } - Write-Host 'GitHub event does not contain pull_request data and no release context was normalized.' - return [PSCustomObject]@{ - Type = 'Validation' - DecisionSource = 'None' - RequiresDecision = $false - CanPublish = $false - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $null - HeadRef = '' - Labels = @() - IsDirectRelease = $false - } + return $null } $labels = @() @@ -251,22 +197,9 @@ function Get-ReleaseContext { } | Format-List | Out-String) Write-Host '-------------------------------------------------' - $isCleanupOnly = [string]$githubEvent.action -eq 'closed' - $defaultBranch = [string]$githubEvent.repository.default_branch - $targetsDefaultBranch = ( - -not [string]::IsNullOrWhiteSpace($defaultBranch) -and - [string]$pr.base.ref -eq $defaultBranch - ) [PSCustomObject]@{ - Type = if ($isCleanupOnly) { 'Cleanup' } else { 'PullRequest' } - DecisionSource = if ($isCleanupOnly) { 'None' } else { 'Labels' } - RequiresDecision = -not $isCleanupOnly - CanPublish = -not $isCleanupOnly -and $targetsDefaultBranch - HasImportantChanges = [bool]$settings.HasImportantChanges - Number = $pr.number - HeadRef = [string]$pr.head.ref - Labels = if ($isCleanupOnly) { @() } else { $labels } - IsDirectRelease = $false + HeadRef = $pr.head.ref + Labels = $labels } } } @@ -277,29 +210,49 @@ function Resolve-ReleaseDecision { Determines whether to publish a release and what kind of version bump to apply. .DESCRIPTION - Evaluates only the canonical release labels or the explicit non-PR input. - Missing, conflicting, and invalid release-capable decisions fail closed. + Evaluates the PR labels against the configured label categories and release type + to produce a complete release decision. .OUTPUTS PSCustomObject with ShouldPublish, CreateRelease, CreatePrerelease, MajorRelease, MinorRelease, PatchRelease, HasVersionBump, and PrereleaseName properties. .EXAMPLE - $decision = Resolve-ReleaseDecision -ReleaseContext $releaseContext + $decision = Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Parameter is used inside LogGroup script block.')] [CmdletBinding()] [OutputType([PSCustomObject])] param( - # The normalized release context. + # The publish configuration object. [Parameter(Mandatory)] - [PSCustomObject] $ReleaseContext + [PSCustomObject] $Configuration, + + # The pull request data object. + [Parameter(Mandatory)] + [PSCustomObject] $PullRequest ) LogGroup 'Determine release configuration' { - $prereleaseName = [string]$ReleaseContext.HeadRef -replace '[^a-zA-Z0-9]' - if (-not $ReleaseContext.RequiresDecision) { + $prereleaseName = $PullRequest.HeadRef -replace '[^a-zA-Z0-9]' + $labels = $PullRequest.Labels + $releaseType = $Configuration.ReleaseType + + $validReleaseTypes = @('Release', 'Prerelease', 'None') + if ([string]::IsNullOrWhiteSpace($releaseType)) { + throw "Settings.Publish.Module.ReleaseType is required. Valid values are: $($validReleaseTypes -join ', ')" + } + if ($releaseType -notin $validReleaseTypes) { + throw "Invalid ReleaseType: [$releaseType]. Valid values are: $($validReleaseTypes -join ', ')" + } + + $createRelease = $releaseType -eq 'Release' + $createPrerelease = $releaseType -eq 'Prerelease' + $shouldPublish = $createRelease -or $createPrerelease + $isCleanupOnly = $releaseType -eq 'None' + + if ($isCleanupOnly) { return [PSCustomObject]@{ ShouldPublish = $false CreateRelease = $false @@ -309,94 +262,63 @@ function Resolve-ReleaseDecision { PatchRelease = $false HasVersionBump = $false PrereleaseName = $prereleaseName - SkipRelease = $false - Bump = 'None' } } - $bumpLabelTypes = [ordered]@{ - 'release:patch' = 'Patch' - 'release:minor' = 'Minor' - 'release:major' = 'Major' + $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 } - $ownedLabelNames = @($bumpLabelTypes.Keys) + @('release:pre-release', 'release:skip') - $ownedLabels = [System.Collections.Generic.HashSet[string]]::new( - [System.StringComparer]::Ordinal - ) - if ($ReleaseContext.DecisionSource -eq 'Input') { - $explicitDecision = [string]$ReleaseContext.ExplicitDecision - $validInputDecisions = @($bumpLabelTypes.Keys) + @('release:skip') - if ($validInputDecisions -cnotcontains $explicitDecision) { - throw ( - "Invalid or missing ReleaseDecision: [$explicitDecision]. Specify exactly one of " + - "$($validInputDecisions -join ', ')." - ) - } - $null = $ownedLabels.Add($explicitDecision) - } else { - foreach ($label in @($ReleaseContext.Labels)) { - if ($ownedLabelNames -ccontains $label) { - $null = $ownedLabels.Add($label) - } - } + $majorLabels = @($labels | Where-Object { $Configuration.MajorLabels -contains $_ }) + $minorLabels = @($labels | Where-Object { $Configuration.MinorLabels -contains $_ }) + $patchLabels = @($labels | Where-Object { $Configuration.PatchLabels -contains $_ }) + $versionLabels = @($majorLabels + $minorLabels + $patchLabels) + + if ($versionLabels.Count -gt 1) { + throw "Conflicting version labels: [$($versionLabels -join ', ')]. Apply exactly one version label." } + if ($ignoreRelease -and $versionLabels.Count -gt 0) { + throw "The ignore label cannot be combined with a version label: [$($versionLabels -join ', ')]." + } + + $majorRelease = $majorLabels.Count -eq 1 + $minorRelease = $minorLabels.Count -eq 1 + $isDirectStableRelease = $createRelease -and $PullRequest.IsDirectRelease + $patchRelease = $patchLabels.Count -eq 1 -or ( + -not $majorRelease -and + -not $minorRelease -and + ($Configuration.AutoPatching -or $isDirectStableRelease) + ) + $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease - $hasSkip = $ownedLabels.Contains('release:skip') - $hasPrerelease = $ownedLabels.Contains('release:pre-release') - $bumpLabels = @($bumpLabelTypes.Keys | Where-Object { $ownedLabels.Contains($_) }) + if (-not $hasVersionBump) { + Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version without publishing.' + $patchRelease = $true + $hasVersionBump = $true + $shouldPublish = $false + } - if ($hasSkip) { - if ($ownedLabels.Count -ne 1) { - throw 'Invalid release labels: release:skip must not be combined with another release label.' - } - } elseif ($bumpLabels.Count -eq 0) { - if ($hasPrerelease) { - throw 'Invalid release labels: release:pre-release requires exactly one release bump label.' - } - throw ( - 'Release decision is missing. Apply exactly one of release:patch, release:minor, ' + - 'release:major, or release:skip.' - ) - } elseif ($bumpLabels.Count -gt 1) { - throw "Conflicting release bump labels: [$($bumpLabels -join ', ')]. Apply exactly one bump label." + if ($ignoreRelease) { + $createRelease = $false + $createPrerelease = $false + $shouldPublish = $false } - $bump = if ($bumpLabels.Count -eq 1) { $bumpLabelTypes[$bumpLabels[0]] } else { 'None' } - $majorRelease = $bump -eq 'Major' - $minorRelease = $bump -eq 'Minor' - $patchRelease = $bump -eq 'Patch' - $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease - $canPublish = [bool]$ReleaseContext.CanPublish -and [bool]$ReleaseContext.HasImportantChanges - $createRelease = -not $hasSkip -and $ReleaseContext.Type -eq 'Stable' -and $canPublish - $publishPrerelease = ( - -not $hasSkip -and - $ReleaseContext.Type -eq 'PullRequest' -and - $hasPrerelease -and - $canPublish - ) - $shouldPublish = $createRelease -or $publishPrerelease - $createPrerelease = ( - -not $hasSkip -and - $ReleaseContext.Type -eq 'PullRequest' -and - $hasVersionBump - ) - if ($createPrerelease -and [string]::IsNullOrWhiteSpace($prereleaseName)) { - throw 'Cannot create a pull-request preview version because the head branch name is missing.' + if (-not $shouldPublish) { + $createPrerelease = $true } Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ - ContextType = $ReleaseContext.Type - ShouldPublish = $shouldPublish - CreateRelease = $createRelease - PublishPrerelease = $publishPrerelease - PreviewVersion = $createPrerelease - Skip = $hasSkip - Bump = $bump - Major = $majorRelease - Minor = $minorRelease - Patch = $patchRelease + ReleaseType = $releaseType + ShouldPublish = $shouldPublish + CreateRelease = $createRelease + CreatePrerelease = $createPrerelease + Major = $majorRelease + Minor = $minorRelease + Patch = $patchRelease } | Format-List | Out-String) Write-Host '-------------------------------------------------' @@ -409,8 +331,6 @@ function Resolve-ReleaseDecision { PatchRelease = $patchRelease HasVersionBump = $hasVersionBump PrereleaseName = $prereleaseName - SkipRelease = $hasSkip - Bump = $bump } } } diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index dfc611d2..ad3b4a71 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -8,9 +8,25 @@ Import-Module -Name "$PSScriptRoot/Resolve-PSModuleVersion.Helpers.psm1" -Force $actionInput = Read-ActionInput $config = Get-PublishConfiguration -SettingsJson $actionInput.SettingsJson -$releaseContext = Get-ReleaseContext -SettingsJson $actionInput.SettingsJson ` - -ReleaseDecision $actionInput.ReleaseDecision -$decision = Resolve-ReleaseDecision -ReleaseContext $releaseContext +$pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson + +$decision = if ($null -eq $pullRequest) { + # Non-PR event (for example workflow_dispatch or schedule): there are no pull request + # labels to determine a version bump, so keep the current published version and publish + # nothing. For a module that has never been released this floors at 0.0.0. + [PSCustomObject]@{ + ShouldPublish = $false + CreateRelease = $false + CreatePrerelease = $false + MajorRelease = $false + MinorRelease = $false + PatchRelease = $false + HasVersionBump = $false + PrereleaseName = '' + } +} else { + Resolve-ReleaseDecision -Configuration $config -PullRequest $pullRequest +} $releases = @(Get-GitHubRelease) $ghVersion = Get-LatestGitHubVersion -Releases $releases 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 3993d52a..4a16a2ea 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -2,10 +2,6 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingCmdletAliases', 'Context', - Justification = 'Context is part of the Pester DSL, not the Get-Context alias.' -)] [CmdletBinding()] param() @@ -21,6 +17,10 @@ BeforeAll { [CmdletBinding()] [OutputType([PSCustomObject])] param( + # Whether an unlabeled pull request is treated as a patch. + [Parameter()] + [bool] $AutoPatching = $true, + # Whether prereleases get an incrementing number suffix. [Parameter()] [bool] $IncrementalPrerelease, @@ -31,13 +31,23 @@ BeforeAll { # The prefix put in front of the version, for example 'v'. [Parameter()] - [string] $VersionPrefix = 'v' + [string] $VersionPrefix = 'v', + + # The release type resolved from the pull request labels. + [Parameter()] + [string] $ReleaseType = 'Release' ) [PSCustomObject]@{ + AutoPatching = $AutoPatching IncrementalPrerelease = $IncrementalPrerelease DatePrereleaseFormat = $DatePrereleaseFormat VersionPrefix = $VersionPrefix + ReleaseType = $ReleaseType + IgnoreLabels = @('NoRelease') + MajorLabels = @('major') + MinorLabels = @('minor') + PatchLabels = @('patch') } } @@ -76,59 +86,6 @@ BeforeAll { PatchRelease = $Bump -eq 'Patch' HasVersionBump = $Bump -ne 'None' PrereleaseName = $PrereleaseName - SkipRelease = $Bump -eq 'None' - Bump = $Bump - } - } - - function Get-TestReleaseContext { - <# - .SYNOPSIS - Builds a normalized release context for decision tests. - #> - [CmdletBinding()] - [OutputType([PSCustomObject])] - param( - [Parameter()] - [ValidateSet('PullRequest', 'Stable', 'Cleanup', 'Validation')] - [string] $Type = 'Stable', - - [Parameter()] - [AllowEmptyCollection()] - [AllowNull()] - [string[]] $Labels = @('release:patch'), - - [Parameter()] - [ValidateSet('Labels', 'Input', 'None')] - [string] $DecisionSource = 'Labels', - - [Parameter()] - [string] $ExplicitDecision = '', - - [Parameter()] - [bool] $RequiresDecision = $true, - - [Parameter()] - [bool] $CanPublish = $true, - - [Parameter()] - [bool] $HasImportantChanges = $true, - - [Parameter()] - [string] $HeadRef = 'feature-release' - ) - - [PSCustomObject]@{ - Type = $Type - DecisionSource = $DecisionSource - RequiresDecision = $RequiresDecision - CanPublish = $CanPublish - HasImportantChanges = $HasImportantChanges - Number = 42 - HeadRef = $HeadRef - Labels = $Labels - ExplicitDecision = $ExplicitDecision - IsDirectRelease = $DecisionSource -eq 'Input' } } } @@ -451,7 +408,7 @@ Describe 'Resolve-PSModuleVersion' { $params = @{ LatestVersion = New-PSSemVer -Version '0.0.0' Decision = Get-TestDecision -Bump 'Patch' -CreatePrerelease $true -PrereleaseName 'mybranch' - Configuration = Get-TestConfiguration + Configuration = Get-TestConfiguration -ReleaseType 'Prerelease' ModuleName = 'MyBrandNewModule' Releases = @() } @@ -529,395 +486,82 @@ Describe 'Resolve-PSModuleVersion' { } } - Describe 'Get-ReleaseContext' { - It 'uses merged pull request labels for an associated default-branch push' { + Describe 'Get-GitHubPullRequest' { + It 'uses the normalized pull request from a default-branch push' { $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'push' - EventAction = '' + Context = @{ IsPushToDefaultBranch = $true DefaultBranch = 'main' PullRequest = @{ Number = 390 HeadRef = 'feature/push-release' - Labels = @('release:minor') + Labels = @('minor') } } } | ConvertTo-Json -Depth 5 - $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:skip' + $result = Get-GitHubPullRequest -SettingsJson $settings - $result.Type | Should -BeExactly 'Stable' - $result.DecisionSource | Should -BeExactly 'Labels' $result.Number | Should -Be 390 - $result.Labels | Should -Be @('release:minor') - $result.IsDirectRelease | Should -BeFalse + $result.HeadRef | Should -Be 'feature/push-release' + $result.Labels | Should -Be @('minor') } - It 'uses the explicit input for an unassociated default-branch push' { + It 'creates default patch context for a direct default-branch push' { $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'push' - EventAction = '' + Context = @{ IsPushToDefaultBranch = $true DefaultBranch = 'main' PullRequest = $null } } | ConvertTo-Json -Depth 5 - $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:major' - - $result.Type | Should -BeExactly 'Stable' - $result.DecisionSource | Should -BeExactly 'Input' - $result.ExplicitDecision | Should -BeExactly 'release:major' - $result.IsDirectRelease | Should -BeTrue - } - - It 'uses the explicit input for a default-branch workflow dispatch' { - $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'workflow_dispatch' - EventAction = '' - IsManualDispatchToDefaultBranch = $true - DefaultBranch = 'main' - PullRequest = $null - } - } | ConvertTo-Json -Depth 5 - - $result = Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:patch' - - $result.Type | Should -BeExactly 'Stable' - $result.DecisionSource | Should -BeExactly 'Input' - $result.CanPublish | Should -BeTrue - } - - It 'creates a cleanup context for a closed pull request' { - $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'pull_request' - EventAction = 'closed' - DefaultBranch = 'main' - PullRequest = @{ - Number = 390 - HeadRef = 'feature/closed' - Labels = @('release:major', 'release:minor') - } - } - } | ConvertTo-Json -Depth 5 - - $result = Get-ReleaseContext -SettingsJson $settings - - $result.Type | Should -BeExactly 'Cleanup' - $result.RequiresDecision | Should -BeFalse - $result.Labels | Should -BeNullOrEmpty - } - - It 'creates a validation context for a scheduled run' { - $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'schedule' - EventAction = '' - DefaultBranch = 'main' - PullRequest = $null - } - } | ConvertTo-Json -Depth 5 - - $result = Get-ReleaseContext -SettingsJson $settings - - $result.Type | Should -BeExactly 'Validation' - $result.RequiresDecision | Should -BeFalse - $result.CanPublish | Should -BeFalse - } - - It 'does not authorize unsupported events that carry pull request data' { - $settings = @{ - HasImportantChanges = $true - Context = @{ - EventName = 'pull_request_target' - EventAction = 'opened' - DefaultBranch = 'main' - PullRequest = @{ - Number = 390 - HeadRef = 'untrusted/fork' - Labels = @('release:patch', 'release:pre-release') - } - } - } | ConvertTo-Json -Depth 5 - - $result = Get-ReleaseContext -SettingsJson $settings + $result = Get-GitHubPullRequest -SettingsJson $settings - $result.Type | Should -BeExactly 'Validation' - $result.RequiresDecision | Should -BeFalse - $result.CanPublish | Should -BeFalse + $result.Number | Should -BeNullOrEmpty + $result.HeadRef | Should -Be 'main' $result.Labels | Should -BeNullOrEmpty - } - - It 'fails closed when a push lacks normalized default-branch context' { - $previousEventName = $env:GITHUB_EVENT_NAME - $previousEventJson = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson - try { - $env:GITHUB_EVENT_NAME = 'push' - $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = '{}' - $settings = @{ HasImportantChanges = $true } | ConvertTo-Json - - { Get-ReleaseContext -SettingsJson $settings -ReleaseDecision 'release:patch' } | - Should -Throw '*without normalized default-branch context*' - } finally { - $env:GITHUB_EVENT_NAME = $previousEventName - $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson = $previousEventJson - } + $result.IsDirectRelease | Should -BeTrue } } Describe 'Resolve-ReleaseDecision' { - It 'resolves a stable release' -ForEach @( - @{ Label = 'release:patch'; Bump = 'Patch' } - @{ Label = 'release:minor'; Bump = 'Minor' } - @{ Label = 'release:major'; Bump = 'Major' } - ) { - $context = Get-TestReleaseContext -Type Stable -Labels @($Label) - - $result = Resolve-ReleaseDecision -ReleaseContext $context + It 'uses the default patch bump for a direct stable release' { + $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -AutoPatching $false) ` + -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @(); IsDirectRelease = $true }) - $result.Bump | Should -BeExactly $Bump $result.ShouldPublish | Should -BeTrue - $result.CreateRelease | Should -BeTrue - $result.CreatePrerelease | Should -BeFalse + $result.PatchRelease | Should -BeTrue } - It 'resolves a pull request prerelease' -ForEach @( - @{ Label = 'release:patch'; Bump = 'Patch' } - @{ Label = 'release:minor'; Bump = 'Minor' } - @{ Label = 'release:major'; Bump = 'Major' } - ) { - $labels = @($Label, 'release:pre-release') - $context = Get-TestReleaseContext -Type PullRequest -Labels $labels - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly $Bump - $result.ShouldPublish | Should -BeTrue - $result.CreateRelease | Should -BeFalse - $result.CreatePrerelease | Should -BeTrue - } + 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 = @() }) - It 'creates a non-publishing preview version for a bump-only pull request' { - $context = Get-TestReleaseContext -Type PullRequest -Labels @('release:minor') - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly 'Minor' $result.ShouldPublish | Should -BeFalse - $result.CreatePrerelease | Should -BeTrue - } - - It 'publishes a merged pull request as stable when prerelease mode remains applied' { - $labels = @('release:minor', 'release:pre-release') - $context = Get-TestReleaseContext -Type Stable -Labels $labels - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.ShouldPublish | Should -BeTrue - $result.CreateRelease | Should -BeTrue - $result.CreatePrerelease | Should -BeFalse + $result.PatchRelease | Should -BeTrue } - It 'resolves release:skip without a version bump' -ForEach @( - @{ Type = 'PullRequest' } - @{ Type = 'Stable' } - ) { - $context = Get-TestReleaseContext -Type $Type -Labels @('release:skip') - - $result = Resolve-ReleaseDecision -ReleaseContext $context + It 'does not validate cleanup-only pull request labels' { + $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -ReleaseType None) ` + -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @('NoRelease', 'patch') }) - $result.Bump | Should -BeExactly 'None' - $result.SkipRelease | Should -BeTrue $result.ShouldPublish | Should -BeFalse $result.HasVersionBump | Should -BeFalse } - It 'ignores unrelated labels alongside one canonical decision' { - $labels = @('dependencies', 'Major', 'release:patch', 'release:unknown') - $context = Get-TestReleaseContext -Type Stable -Labels $labels - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly 'Patch' - $result.ShouldPublish | Should -BeTrue + It 'rejects multiple version labels' { + { + Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` + -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('major', 'patch') }) + } | Should -Throw '*Conflicting version labels*' } - It 'validates but does not publish an unimportant canonical change' { - $contextParams = @{ - Type = 'Stable' - Labels = @('release:major') - HasImportantChanges = $false - } - $context = Get-TestReleaseContext @contextParams - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly 'Major' - $result.ShouldPublish | Should -BeFalse - } - - It 'rejects ' -ForEach @( - @{ - Name = 'an empty label set' - Labels = @() - Message = '*Release decision is missing*' - } - @{ - Name = 'legacy bare labels' - Labels = @('Major', 'Minor', 'Patch', 'Prerelease', 'NoRelease') - Message = '*Release decision is missing*' - } - @{ - Name = 'lowercase bare labels and aliases' - Labels = @('major', 'minor', 'patch', 'prerelease', 'breaking', 'feature', 'fix') - Message = '*Release decision is missing*' - } - @{ - Name = 'noncanonical casing' - Labels = @('Release:Patch') - Message = '*Release decision is missing*' - } - @{ - Name = 'prerelease without a bump' - Labels = @('release:pre-release') - Message = '*release:pre-release requires exactly one release bump label*' - } - @{ - Name = 'patch and minor' - Labels = @('release:patch', 'release:minor') - Message = '*Conflicting release bump labels*' - } - @{ - Name = 'minor and major' - Labels = @('release:minor', 'release:major') - Message = '*Conflicting release bump labels*' - } - @{ - Name = 'patch and major' - Labels = @('release:patch', 'release:major') - Message = '*Conflicting release bump labels*' - } - @{ - Name = 'all bumps' - Labels = @('release:patch', 'release:minor', 'release:major') - Message = '*Conflicting release bump labels*' - } - @{ - Name = 'skip and patch' - Labels = @('release:skip', 'release:patch') - Message = '*release:skip must not be combined*' - } - @{ - Name = 'skip and prerelease' - Labels = @('release:skip', 'release:pre-release') - Message = '*release:skip must not be combined*' - } - ) { - $context = Get-TestReleaseContext -Type PullRequest -Labels $Labels - - { Resolve-ReleaseDecision -ReleaseContext $context } | - Should -Throw $Message - } - - It 'resolves an explicit decision' -ForEach @( - @{ Decision = 'release:patch'; Bump = 'Patch' } - @{ Decision = 'release:minor'; Bump = 'Minor' } - @{ Decision = 'release:major'; Bump = 'Major' } - ) { - $contextParams = @{ - Type = 'Stable' - DecisionSource = 'Input' - ExplicitDecision = $Decision - Labels = @() - } - $context = Get-TestReleaseContext @contextParams - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly $Bump - $result.CreateRelease | Should -BeTrue - } - - It 'resolves explicit release:skip' { - $contextParams = @{ - Type = 'Stable' - DecisionSource = 'Input' - ExplicitDecision = 'release:skip' - Labels = @() - } - $context = Get-TestReleaseContext @contextParams - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.SkipRelease | Should -BeTrue - $result.ShouldPublish | Should -BeFalse - } - - It 'rejects the invalid explicit decision ' -ForEach @( - @{ Decision = ''; Message = '*Invalid or missing ReleaseDecision*' } - @{ Decision = 'release:pre-release'; Message = '*Invalid or missing ReleaseDecision*' } - @{ Decision = 'patch'; Message = '*Invalid or missing ReleaseDecision*' } - @{ Decision = 'Release:Patch'; Message = '*Invalid or missing ReleaseDecision*' } - ) { - $contextParams = @{ - Type = 'Stable' - DecisionSource = 'Input' - ExplicitDecision = $Decision - Labels = @() - } - $context = Get-TestReleaseContext @contextParams - - { Resolve-ReleaseDecision -ReleaseContext $context } | - Should -Throw $Message - } - - It 'does not let an explicit input override associated pull request labels' { - $context = Get-TestReleaseContext -Type Stable -Labels @('release:minor') - $context | Add-Member -MemberType NoteProperty ` - -Name ExplicitDecision -Value 'release:major' -Force - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.Bump | Should -BeExactly 'Minor' - } - - It 'bypasses a release decision for ' -ForEach @( - @{ Type = 'Cleanup' } - @{ Type = 'Validation' } - ) { - $contextParams = @{ - Type = $Type - DecisionSource = 'None' - RequiresDecision = $false - Labels = @('release:major', 'release:minor') - } - $context = Get-TestReleaseContext @contextParams - - $result = Resolve-ReleaseDecision -ReleaseContext $context - - $result.ShouldPublish | Should -BeFalse - $result.HasVersionBump | Should -BeFalse - } - - It 'rejects a pull request context without a head branch' { - $contextParams = @{ - Type = 'PullRequest' - Labels = @('release:patch') - HeadRef = '' - } - $context = Get-TestReleaseContext @contextParams - - { Resolve-ReleaseDecision -ReleaseContext $context } | - Should -Throw '*head branch name is missing*' + It 'rejects a NoRelease label combined with a version label' { + { + Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` + -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('NoRelease', 'patch') }) + } | Should -Throw '*ignore label cannot be combined*' } } } From 06174d226a53bd3e97289d95cf69cef42d8da77b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 00:14:44 +0200 Subject: [PATCH 05/21] Default module releases to namespaced labels --- .../src/Get-PSModuleSettings.Helpers.psm1 | 41 +++++++++ .../src/Settings.schema.json | 18 ++-- .../actions/Get-PSModuleSettings/src/main.ps1 | 17 +--- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 46 ++++++++++ .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 84 ++++++++++++++++--- 5 files changed, 173 insertions(+), 33 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index ef5aef29..6f7fba66 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -79,6 +79,47 @@ function Resolve-WorkflowEventRouting { } } +function Resolve-PSModulePublishSetting { + <# + .SYNOPSIS + Resolves module publication settings and their defaults. + + .DESCRIPTION + Applies Process-PSModule defaults while preserving every consumer-provided + publication and release-label mapping. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + .EXAMPLE + Resolve-PSModulePublishSetting -PublishModule $settings.Publish.Module + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [AllowNull()] + [object] $PublishModule + ) + + [pscustomobject]@{ + Skip = $PublishModule.Skip ?? $false + AutoCleanup = $PublishModule.AutoCleanup ?? $true + AutoPatching = $PublishModule.AutoPatching ?? $true + IncrementalPrerelease = $PublishModule.IncrementalPrerelease ?? $true + DatePrereleaseFormat = $PublishModule.DatePrereleaseFormat ?? '' + VersionPrefix = $PublishModule.VersionPrefix ?? 'v' + MajorLabels = $PublishModule.MajorLabels ?? 'release:major' + MinorLabels = $PublishModule.MinorLabels ?? 'release:minor' + PatchLabels = $PublishModule.PatchLabels ?? 'release:patch' + IgnoreLabels = $PublishModule.IgnoreLabels ?? 'release:skip' + PrereleaseLabels = $PublishModule.PrereleaseLabels ?? 'release:pre-release' + UsePRTitleAsReleaseName = $PublishModule.UsePRTitleAsReleaseName ?? $false + UsePRBodyAsReleaseNotes = $PublishModule.UsePRBodyAsReleaseNotes ?? $true + UsePRTitleAsNotesHeading = $PublishModule.UsePRTitleAsNotesHeading ?? $true + } +} + function Select-PullRequestForPush { <# .SYNOPSIS diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index a51b90ac..7f34db64 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -140,7 +140,8 @@ }, "AutoPatching": { "type": "boolean", - "description": "Automatically apply patches" + "description": "Automatically apply patches", + "default": true }, "IncrementalPrerelease": { "type": "boolean", @@ -156,23 +157,28 @@ }, "MajorLabels": { "type": "string", - "description": "Comma-separated labels that trigger major version bump" + "description": "Comma-separated labels that trigger major version bump", + "default": "release:major" }, "MinorLabels": { "type": "string", - "description": "Comma-separated labels that trigger minor version bump" + "description": "Comma-separated labels that trigger minor version bump", + "default": "release:minor" }, "PatchLabels": { "type": "string", - "description": "Comma-separated labels that trigger patch version bump" + "description": "Comma-separated labels that trigger patch version bump", + "default": "release:patch" }, "IgnoreLabels": { "type": "string", - "description": "Comma-separated labels that prevent release" + "description": "Comma-separated labels that prevent release", + "default": "release:skip" }, "PrereleaseLabels": { "type": "string", - "description": "Comma-separated labels that trigger a prerelease" + "description": "Comma-separated labels that trigger a prerelease", + "default": "release:pre-release" }, "UsePRTitleAsReleaseName": { "type": "boolean", diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index da1ad72b..dd13d405 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -187,22 +187,7 @@ $settings = [pscustomobject]@{ } } Publish = [pscustomobject]@{ - Module = [pscustomobject]@{ - Skip = $settings.Publish.Module.Skip ?? $false - AutoCleanup = $settings.Publish.Module.AutoCleanup ?? $true - AutoPatching = $settings.Publish.Module.AutoPatching ?? $true - IncrementalPrerelease = $settings.Publish.Module.IncrementalPrerelease ?? $true - DatePrereleaseFormat = $settings.Publish.Module.DatePrereleaseFormat ?? '' - VersionPrefix = $settings.Publish.Module.VersionPrefix ?? 'v' - MajorLabels = $settings.Publish.Module.MajorLabels ?? 'major, breaking' - MinorLabels = $settings.Publish.Module.MinorLabels ?? 'minor, feature' - PatchLabels = $settings.Publish.Module.PatchLabels ?? 'patch, fix' - IgnoreLabels = $settings.Publish.Module.IgnoreLabels ?? 'NoRelease' - PrereleaseLabels = $settings.Publish.Module.PrereleaseLabels ?? 'prerelease' - UsePRTitleAsReleaseName = $settings.Publish.Module.UsePRTitleAsReleaseName ?? $false - UsePRBodyAsReleaseNotes = $settings.Publish.Module.UsePRBodyAsReleaseNotes ?? $true - UsePRTitleAsNotesHeading = $settings.Publish.Module.UsePRTitleAsNotesHeading ?? $true - } + Module = Resolve-PSModulePublishSetting -PublishModule $settings.Publish.Module Site = [pscustomobject]@{ Skip = $settings.Publish.Site.Skip ?? $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..40e86621 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -82,6 +82,52 @@ Describe 'Resolve-WorkflowEventRouting' { } } +Describe 'Resolve-PSModulePublishSetting' { + It 'uses the canonical release-label defaults' { + $result = Resolve-PSModulePublishSetting -PublishModule $null + + $result.AutoPatching | Should -BeTrue + $result.MajorLabels | Should -BeExactly 'release:major' + $result.MinorLabels | Should -BeExactly 'release:minor' + $result.PatchLabels | Should -BeExactly 'release:patch' + $result.PrereleaseLabels | Should -BeExactly 'release:pre-release' + $result.IgnoreLabels | Should -BeExactly 'release:skip' + } + + It 'preserves consumer-controlled release settings' { + $publishModule = [pscustomobject]@{ + AutoPatching = $false + MajorLabels = 'custom:major' + MinorLabels = 'custom:minor' + PatchLabels = 'custom:patch' + PrereleaseLabels = 'custom:pre-release' + IgnoreLabels = 'custom:skip' + } + + $result = Resolve-PSModulePublishSetting -PublishModule $publishModule + + $result.AutoPatching | Should -BeFalse + $result.MajorLabels | Should -BeExactly 'custom:major' + $result.MinorLabels | Should -BeExactly 'custom:minor' + $result.PatchLabels | Should -BeExactly 'custom:patch' + $result.PrereleaseLabels | Should -BeExactly 'custom:pre-release' + $result.IgnoreLabels | Should -BeExactly 'custom:skip' + } + + It 'declares the same canonical defaults in the settings schema' { + $schemaPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/Settings.schema.json' + $schema = Get-Content -Path $schemaPath -Raw | ConvertFrom-Json + $moduleProperties = $schema.properties.Publish.properties.Module.properties + + $moduleProperties.AutoPatching.default | Should -BeTrue + $moduleProperties.MajorLabels.default | Should -BeExactly 'release:major' + $moduleProperties.MinorLabels.default | Should -BeExactly 'release:minor' + $moduleProperties.PatchLabels.default | Should -BeExactly 'release:patch' + $moduleProperties.PrereleaseLabels.default | Should -BeExactly 'release:pre-release' + $moduleProperties.IgnoreLabels.default | Should -BeExactly 'release:skip' + } +} + Describe 'Select-PullRequestForPush' { It 'selects the merged PR whose merge commit matches the pushed commit' { $pullRequests = @( 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..2b5cf1ee 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -35,7 +35,23 @@ BeforeAll { # The release type resolved from the pull request labels. [Parameter()] - [string] $ReleaseType = 'Release' + [string] $ReleaseType = 'Release', + + # Labels indicating no release. + [Parameter()] + [string[]] $IgnoreLabels = @('release:skip'), + + # Labels indicating a major release. + [Parameter()] + [string[]] $MajorLabels = @('release:major'), + + # Labels indicating a minor release. + [Parameter()] + [string[]] $MinorLabels = @('release:minor'), + + # Labels indicating a patch release. + [Parameter()] + [string[]] $PatchLabels = @('release:patch') ) [PSCustomObject]@{ @@ -44,10 +60,10 @@ BeforeAll { DatePrereleaseFormat = $DatePrereleaseFormat VersionPrefix = $VersionPrefix ReleaseType = $ReleaseType - IgnoreLabels = @('NoRelease') - MajorLabels = @('major') - MinorLabels = @('minor') - PatchLabels = @('patch') + IgnoreLabels = $IgnoreLabels + MajorLabels = $MajorLabels + MinorLabels = $MinorLabels + PatchLabels = $PatchLabels } } @@ -495,7 +511,7 @@ Describe 'Resolve-PSModuleVersion' { PullRequest = @{ Number = 390 HeadRef = 'feature/push-release' - Labels = @('minor') + Labels = @('release:minor') } } } | ConvertTo-Json -Depth 5 @@ -504,7 +520,7 @@ Describe 'Resolve-PSModuleVersion' { $result.Number | Should -Be 390 $result.HeadRef | Should -Be 'feature/push-release' - $result.Labels | Should -Be @('minor') + $result.Labels | Should -Be @('release:minor') } It 'creates default patch context for a direct default-branch push' { @@ -544,7 +560,7 @@ Describe 'Resolve-PSModuleVersion' { It 'does not validate cleanup-only pull request labels' { $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -ReleaseType None) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @('NoRelease', 'patch') }) + -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @('release:skip', 'release:patch') }) $result.ShouldPublish | Should -BeFalse $result.HasVersionBump | Should -BeFalse @@ -553,15 +569,61 @@ Describe 'Resolve-PSModuleVersion' { It 'rejects multiple version labels' { { Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('major', 'patch') }) + -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('release:major', 'release:patch') }) } | Should -Throw '*Conflicting version labels*' } - It 'rejects a NoRelease label combined with a version label' { + It 'rejects release:skip combined with a version label' { { Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) ` - -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('NoRelease', 'patch') }) + -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @('release:skip', 'release:patch') }) } | Should -Throw '*ignore label cannot be combined*' } + + It 'honors the canonical default' -ForEach @( + @{ Bump = 'Major'; Label = 'release:major'; Flag = 'MajorRelease' } + @{ Bump = 'Minor'; Label = 'release:minor'; Flag = 'MinorRelease' } + @{ Bump = 'Patch'; Label = 'release:patch'; Flag = 'PatchRelease' } + ) { + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @($Label) + } + + $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration) -PullRequest $pullRequest + + $result.$Flag | Should -BeTrue + $result.ShouldPublish | Should -BeTrue + } + + It 'honors the configured label mapping' -ForEach @( + @{ Bump = 'Major'; Setting = 'MajorLabels'; Label = 'custom:major'; Flag = 'MajorRelease' } + @{ Bump = 'Minor'; Setting = 'MinorLabels'; Label = 'custom:minor'; Flag = 'MinorRelease' } + @{ Bump = 'Patch'; Setting = 'PatchLabels'; Label = 'custom:patch'; Flag = 'PatchRelease' } + ) { + $configuration = Get-TestConfiguration + $configuration.$Setting = @($Label) + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @($Label) + } + + $result = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest + + $result.$Flag | Should -BeTrue + $result.ShouldPublish | Should -BeTrue + } + + It 'honors a configured ignore label' { + $configuration = Get-TestConfiguration -IgnoreLabels @('custom:skip') + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @('custom:skip') + } + + $result = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest + + $result.ShouldPublish | Should -BeFalse + } } } From 5f1bf270efe158c64c65370f362797e97f24042a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 00:27:59 +0200 Subject: [PATCH 06/21] Document namespaced release defaults --- .github/workflows/workflow.yml | 2 +- docs/content/get-started/repository-setup.md | 2 +- .../content/get-started/your-first-release.md | 24 +++++++------- docs/content/guides/calling-the-workflow.md | 10 +++--- .../guides/github-app-authentication.md | 2 +- .../content/guides/versioning-and-releases.md | 31 ++++++++++--------- .../reference/powershell-module-standard.md | 30 +++++++++++------- docs/content/reference/repository-standard.md | 5 +-- docs/content/reference/scenario-matrix.md | 2 +- docs/content/reference/settings.md | 18 ++++++----- docs/content/specification/design.md | 5 +-- .../specification/principles-and-practices.md | 5 +-- docs/content/specification/spec.md | 19 ++++++------ 13 files changed, 86 insertions(+), 69 deletions(-) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index db9a0999..c53484ec 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -251,7 +251,7 @@ jobs: Settings: ${{ needs.Plan.outputs.Settings }} # Runs on: - # - ✅ Open/Updated PR - Only with prerelease label: publishes prerelease version + # - ✅ Open/Updated PR - With prerelease intent and a resolved bump: publishes a prerelease # - ✅ Default push - Publishes a stable release when all tests/coverage/build succeed # - ✅ Closed PR - Cleans up prereleases for the closed branch (no version published) # - ✅ Manual run - Publishes a stable default-branch release diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 790cf5ea..fae80034 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -63,7 +63,7 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} diff --git a/docs/content/get-started/your-first-release.md b/docs/content/get-started/your-first-release.md index e8b00fb1..d21e7e79 100644 --- a/docs/content/get-started/your-first-release.md +++ b/docs/content/get-started/your-first-release.md @@ -29,24 +29,26 @@ release with commit-based notes. It has no pull-request labels or body to use as | Label | Effect | | --- | --- | -| `major` / `breaking` | Bump `MAJOR`. | -| `minor` / `feature` | Bump `MINOR`. | -| `patch` / `fix` | Bump `PATCH`. This is the default for an unlabeled PR when `AutoPatching` is enabled. | -| `Prerelease` | Publish a prerelease version from the pull request, before it is merged. | -| `NoRelease` | Run the pipeline but skip publication. | +| `release:major` | Bump `MAJOR`. | +| `release:minor` | Bump `MINOR`. | +| `release:patch` | Bump `PATCH`. This is the default for an unlabeled PR when `AutoPatching` is enabled. | +| `release:pre-release` | Publish a prerelease version from the pull request, before it is merged. | +| `release:skip` | Run the pipeline but skip publication. | -Conflicting labels (for example `major` together with `NoRelease`) are rejected and block the merge. The label names are -configurable — see [Settings](../reference/settings.md). +Conflicting labels (for example `release:major` together with `release:skip`) are rejected whenever that run resolves a +release. A prerelease conflict fails the pull-request check; a stable conflict fails the resulting release run. These +are the defaults; every label mapping remains configurable — see [Settings](../reference/settings.md). For the full model, including prerelease promotion and what a release produces, see [Versioning and releases](../guides/versioning-and-releases.md). ## Testing before you merge -Add the `Prerelease` label to publish a prerelease version from the open pull request. The prerelease is installable -from the PowerShell Gallery but is not promoted as the latest stable version, so it can be validated before the pull -request is merged. When the pull request is closed without merging, the prerelease versions and tags created for it are -cleaned up automatically. +With the default `AutoPatching: true`, add `release:pre-release` to publish a patch prerelease from the open pull +request. When AutoPatching is disabled, also apply one configured bump label. The prerelease is installable from the +PowerShell Gallery but is not promoted as the latest stable version, so it can be validated before the pull request is +merged. When the pull request is closed without merging, the prerelease versions and tags created for it are cleaned up +automatically. ## When nothing is released diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 8e18d7e1..2890de4d 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -46,7 +46,7 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -92,7 +92,7 @@ changes: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -121,7 +121,7 @@ content lines stay at the same indentation level: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -233,7 +233,7 @@ You can also pass patterns via the workflow input: ```yaml jobs: Process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 with: ImportantFilePatterns: | ^src/ @@ -246,7 +246,7 @@ To disable triggering via the workflow input, pass an explicit empty string: ```yaml jobs: process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 with: ImportantFilePatterns: '' ``` diff --git a/docs/content/guides/github-app-authentication.md b/docs/content/guides/github-app-authentication.md index cf35933a..94574e24 100644 --- a/docs/content/guides/github-app-authentication.md +++ b/docs/content/guides/github-app-authentication.md @@ -23,7 +23,7 @@ names. Map the caller's secrets explicitly: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 7cbe9cf1..e76596cf 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -6,8 +6,8 @@ description: How Process-PSModule resolves a version from pull-request labels, p # Versioning and releases Process-PSModule orchestrates the module lifecycle through GitHub Actions. Version progression is label-driven in pull -requests and resolved once, in the Plan stage, before anything is built. Stable publication occurs only from a push to -the configured default branch. +requests and resolved once, in the Plan stage, before anything is built. Stable publication occurs from a push or +manual dispatch on the configured default branch. ## Flow @@ -29,18 +29,20 @@ The bump comes from the pull-request label; the next version is computed as `cur | Label | Effect | | --- | --- | -| `major` / `breaking` | Breaking change; bump `MAJOR`. | -| `minor` / `feature` | New feature; bump `MINOR`. | -| `patch` / `fix` | Bugfix; bump `PATCH`. | -| `Prerelease` | Publish as a prerelease; not promoted to latest. | -| `NoRelease` | Run the pipeline, skip publication. | - -Multiple or conflicting version labels (for example `major` together with `NoRelease`) are rejected and block the merge. +| `release:major` | Breaking change; bump `MAJOR`. | +| `release:minor` | New feature; bump `MINOR`. | +| `release:patch` | Bugfix; bump `PATCH`. | +| `release:pre-release` | Publish as a prerelease; not promoted to latest. | +| `release:skip` | Run the pipeline, skip publication. | + +Multiple or conflicting version labels (for example `release:major` together with `release:skip`) are rejected whenever +a release is resolved. A prerelease conflict fails the pull-request check; a stable conflict fails the resulting +default-branch release run. With `AutoPatching: true`, an unlabeled pull request defaults to `Patch`; otherwise it needs an explicit version label. Direct pushes and manual dispatches on the default branch always use `Patch`, regardless of `AutoPatching`. -The label names are configurable through `Publish.Module.MajorLabels`, `MinorLabels`, `PatchLabels`, and -`IgnoreLabels` — see [Settings](../reference/settings.md). +These are default names. They remain configurable through `Publish.Module.MajorLabels`, `MinorLabels`, `PatchLabels`, +`PrereleaseLabels`, and `IgnoreLabels` — see [Settings](../reference/settings.md). ## Branch types @@ -54,9 +56,10 @@ Exactly one branch is authorized to publish stable releases, so consumers always ## Prereleases -A pull request labelled `Prerelease` publishes a prerelease version (for example `v1.2.3-pr.1.5`) that is installable -but not promoted as latest. When that pull request is merged with a version label, the stable version is computed from -the label and the current version on the release branch. +With `AutoPatching: true`, a pull request labeled with the default `release:pre-release` name publishes a patch +prerelease version (for example `v1.2.3-pr.1.5`) that is installable but not promoted as latest. When AutoPatching is +disabled, the pull request also needs one configured bump label. When that pull request is merged with a version label, +the stable version is computed from the label and the current version on the release branch. When a pull request closes, the prerelease versions and tags created for it are removed, so abandoned or promoted work leaves no orphaned prereleases. This is controlled by `Publish.Module.AutoCleanup`. diff --git a/docs/content/reference/powershell-module-standard.md b/docs/content/reference/powershell-module-standard.md index 31d6ee32..621c14a7 100644 --- a/docs/content/reference/powershell-module-standard.md +++ b/docs/content/reference/powershell-module-standard.md @@ -117,8 +117,8 @@ versions — security fixes go on the current tip of `main` only. ### Release and feature branches -For large work, open a release branch and target it from feature branches. Apply the `Prerelease` label on the release -branch PR to publish preview versions before its final merge creates the stable default-branch push. +For large work, open a release branch and target it from feature branches. Apply the default `release:pre-release` label +on the release branch PR to publish preview versions before its final merge creates the stable default-branch push. ## CI/CD pipeline @@ -200,13 +200,18 @@ The **Plan** job resolves the next version before any build occurs. This means t | Labels (configurable) | Bump type | Default label values | | --------------------- | --------- | -------------------- | -| Major | Major (`X.0.0`) | `major`, `breaking` | -| Minor | Minor (`x.Y.0`) | `minor`, `feature` | -| Patch | Patch (`x.y.Z`) | `patch`, `fix` | -| Ignore | No release | `NoRelease` | +| Major | Major (`X.0.0`) | `release:major` | +| Minor | Minor (`x.Y.0`) | `release:minor` | +| Patch | Patch (`x.y.Z`) | `release:patch` | +| Ignore | No release | `release:skip` | | None of the above | Patch (when `AutoPatching: true`) | — | -**Prerelease versions:** Adding a `Prerelease` label to the PR produces a prerelease tag (e.g., `1.2.3-preview0001`). The format is controlled by `IncrementalPrerelease` (sequential numbering) or `DatePrereleaseFormat` (.NET DateTime format string). +**Prerelease versions:** With `AutoPatching: true`, adding the default `release:pre-release` label to the PR produces a +patch prerelease tag (e.g., `1.2.3-preview0001`). When AutoPatching is disabled, also apply one configured bump label. +The format is controlled by `IncrementalPrerelease` (sequential numbering) or `DatePrereleaseFormat` (.NET DateTime +format string). + +All five label mappings remain configurable in `.github/PSModule.yml`. An important direct default-branch push and a default-branch manual dispatch always resolve to `Patch`, regardless of `AutoPatching`. A push that exactly matches a merged pull request uses that PR's version label instead. @@ -263,10 +268,11 @@ Publish: IncrementalPrerelease: true # Sequential prerelease numbering DatePrereleaseFormat: '' # Alternative: .NET DateTime format for prerelease VersionPrefix: 'v' # Git tag prefix - MajorLabels: 'major, breaking' - MinorLabels: 'minor, feature' - PatchLabels: 'patch, fix' - IgnoreLabels: 'NoRelease' + MajorLabels: 'release:major' + MinorLabels: 'release:minor' + PatchLabels: 'release:patch' + IgnoreLabels: 'release:skip' + PrereleaseLabels: 'release:pre-release' UsePRTitleAsReleaseName: false UsePRBodyAsReleaseNotes: true UsePRTitleAsNotesHeading: true @@ -291,7 +297,7 @@ The publish step only runs when: - All tests and code coverage pass (or are skipped) - An important push reaches the default branch (stable release), or -- The PR carries the `Prerelease` label (prerelease from the feature/release branch) +- The PR carries the configured prerelease label and resolves a bump On any closed PR, the pipeline cleans up any prerelease tags created for that branch. A closed pull request cannot create a stable release. diff --git a/docs/content/reference/repository-standard.md b/docs/content/reference/repository-standard.md index dbe7a6d0..908bbe88 100644 --- a/docs/content/reference/repository-standard.md +++ b/docs/content/reference/repository-standard.md @@ -376,8 +376,9 @@ Module repositories use the Process-PSModule workflow. Version and release behav Default expectations: -- `Major`, `Minor`, `Patch`, and `Prerelease` labels determine release behavior. -- Documentation-only README standardization PRs use the `Docs`/`NoRelease` behavior when available. +- `release:major`, `release:minor`, `release:patch`, and `release:pre-release` are the default release labels. +- Documentation-only README standardization PRs use the default `release:skip` behavior when available. +- Module repositories may override these defaults through `.github/PSModule.yml`. - Source changes under `src/` are module-impacting and should trigger the full module workflow. - README and documentation changes should update the site without pretending to be module API changes. diff --git a/docs/content/reference/scenario-matrix.md b/docs/content/reference/scenario-matrix.md index d9c5490f..8ed1ec5e 100644 --- a/docs/content/reference/scenario-matrix.md +++ b/docs/content/reference/scenario-matrix.md @@ -28,7 +28,7 @@ execution; other pages link here rather than repeating it. - \* Only when `Publish.Site.Skip` is `false`. - † Requires an important change and all required build, test, and coverage gates to succeed. An open PR also requires - the `Prerelease` label. A default-branch push uses labels and notes only when its SHA exactly matches a merged pull + the configured prerelease label (`release:pre-release` by default) and a resolved bump. A default-branch push uses labels and notes only when its SHA exactly matches a merged pull request; otherwise it releases a Patch version with commit-based notes. A default-branch manual run is also a Patch release with commit-based notes. - ‡ Cleans up prerelease versions and tags for the closed pull request when `Publish.Module.AutoCleanup` is enabled; diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index c2121220..41380ff3 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -65,10 +65,11 @@ For worked examples, see [Configuring the pipeline](../guides/configuring-the-pi | `Publish.Module.IncrementalPrerelease` | `Boolean` | Use incremental prerelease versioning | `true` | | `Publish.Module.DatePrereleaseFormat` | `String` | Format for date-based prerelease (uses [.NET DateTime format strings](https://learn.microsoft.com/dotnet/standard/base-types/standard-date-and-time-format-strings)) | `''` | | `Publish.Module.VersionPrefix` | `String` | Prefix for version tags | `'v'` | -| `Publish.Module.MajorLabels` | `String` | Labels indicating a major version bump | `'major, breaking'` | -| `Publish.Module.MinorLabels` | `String` | Labels indicating a minor version bump | `'minor, feature'` | -| `Publish.Module.PatchLabels` | `String` | Labels indicating a patch version bump | `'patch, fix'` | -| `Publish.Module.IgnoreLabels` | `String` | Labels indicating no release | `'NoRelease'` | +| `Publish.Module.MajorLabels` | `String` | Labels indicating a major version bump | `'release:major'` | +| `Publish.Module.MinorLabels` | `String` | Labels indicating a minor version bump | `'release:minor'` | +| `Publish.Module.PatchLabels` | `String` | Labels indicating a patch version bump | `'release:patch'` | +| `Publish.Module.IgnoreLabels` | `String` | Labels indicating no release | `'release:skip'` | +| `Publish.Module.PrereleaseLabels` | `String` | Labels indicating a prerelease | `'release:pre-release'` | | `Publish.Module.UsePRTitleAsReleaseName` | `Boolean` | Use the PR title as the GitHub release name instead of version string | `false` | | `Publish.Module.UsePRBodyAsReleaseNotes` | `Boolean` | Use the PR body as the release notes content | `true` | | `Publish.Module.UsePRTitleAsNotesHeading` | `Boolean` | Prepend PR title as H1 heading with PR number link before the body | `true` | @@ -147,10 +148,11 @@ Publish: IncrementalPrerelease: true DatePrereleaseFormat: '' VersionPrefix: 'v' - MajorLabels: 'major, breaking' - MinorLabels: 'minor, feature' - PatchLabels: 'patch, fix' - IgnoreLabels: 'NoRelease' + MajorLabels: 'release:major' + MinorLabels: 'release:minor' + PatchLabels: 'release:patch' + IgnoreLabels: 'release:skip' + PrereleaseLabels: 'release:pre-release' UsePRTitleAsReleaseName: false UsePRBodyAsReleaseNotes: true UsePRTitleAsNotesHeading: true diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 521b71b7..05eb7481 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -50,8 +50,9 @@ That enriched object is an internal inter-workflow contract, not an authoring fo Release intent is resolved once, in the Plan job. A default-branch push resolves the merged pull request for its labels only when its merge commit exactly matches the pushed SHA; a direct push or manual dispatch defaults to `Patch` regardless of `AutoPatching`. Closed pull requests clean up prereleases but cannot authorize a stable release. The -label-to-bump mapping, handling of conflicting labels, and branch types that may publish are documented in -[Versioning and releases](../guides/versioning-and-releases.md). +default label-to-bump mapping is `release:major`, `release:minor`, `release:patch`, `release:pre-release`, and +`release:skip`. Repositories can override those names in `.github/PSModule.yml`. Handling of conflicting labels and +branch types that may publish are documented in [Versioning and releases](../guides/versioning-and-releases.md). Tests run on **Windows** (latest), **Linux** (Ubuntu latest), and **macOS** (latest). Failures on any platform block the build. Each platform runs four suites in parallel: diff --git a/docs/content/specification/principles-and-practices.md b/docs/content/specification/principles-and-practices.md index 06e806d2..f0351c2f 100644 --- a/docs/content/specification/principles-and-practices.md +++ b/docs/content/specification/principles-and-practices.md @@ -16,8 +16,9 @@ patch. If you need to work forth a bigger release, create a branch representing the release (a release branch) and open a PR towards `main` for this branch. For each topic or feature to add to the release, open a new branch representing the feature (a feature branch) and open a PR towards the release -branch. Optionally add the `Prerelease` label on the PR for the release branch, to release preview versions before merging and releasing a published -version of the PowerShell module. +branch. Optionally add the configured prerelease label (`release:pre-release` by default) on the PR for the release +branch. AutoPatching supplies a patch bump by default; when it is disabled, add one configured bump label to publish +preview versions before merging and releasing a published version of the PowerShell module. ## Colocation of concerns diff --git a/docs/content/specification/spec.md b/docs/content/specification/spec.md index 637bd4c9..4c74049d 100644 --- a/docs/content/specification/spec.md +++ b/docs/content/specification/spec.md @@ -43,11 +43,12 @@ The pipeline MUST generate module documentation from the source (cmdlet help, RE ### FR5 — Support label-driven versioning and publication { #fr5 } -The pipeline MUST read pull-request labels (`Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`) to decide the -semantic-version bump when the merged pull request exactly matches a default-branch push. It MUST compute the next -version automatically, never reading or writing a hand-edited version file. An important push to the release branch -MUST trigger publication to the PowerShell Gallery and documentation site; a prerelease label MUST result in a -prerelease version available for testing before stable release. +The pipeline MUST default to the namespaced pull-request labels `release:major`, `release:minor`, `release:patch`, +`release:pre-release`, and `release:skip` when deciding publication and the semantic-version bump. Repositories MAY +override each mapping through `.github/PSModule.yml`. It MUST compute the next version automatically, never reading or +writing a hand-edited version file. An important push to the release branch MUST trigger publication to the PowerShell +Gallery and documentation site. With AutoPatching enabled, the configured prerelease label MUST resolve a patch +prerelease; when AutoPatching is disabled, a configured bump label MUST also be present. ### FR6 — Produce immutable, linkable releases { #fr6 } @@ -92,14 +93,14 @@ Scenario: Merge a valid pull request to main ```gherkin Scenario: Compute the next version from the merged PR label - Given a pull request with the label "Minor" + Given a pull request with the default label "release:minor" And its merge commit is pushed to main And the current version is v1.2.3 Then the new version is computed as v1.3.0 Scenario: Reject ambiguous version labels - Given a pull request with both "Major" and "Minor" labels - When the merge is attempted + Given a pull request with "release:pre-release", "release:major", and "release:minor" labels + When the pull-request pipeline runs Then the build fails and the merge is blocked ``` @@ -119,7 +120,7 @@ Scenario: Publish a module after a stable release ```gherkin Scenario: Publish a prerelease version - Given a pull request with the label "Prerelease" + Given a pull request with the default label "release:pre-release" When the PR runs the pipeline Then a prerelease version is published (e.g., v1.2.3-pr.1.N) And it is available for testing before the PR is merged From 00f66e7f3f4922e1f1c2cd7d134dabfa11742c2e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 00:32:33 +0200 Subject: [PATCH 07/21] Keep caller guidance on the released workflow --- docs/content/get-started/repository-setup.md | 2 +- docs/content/guides/calling-the-workflow.md | 10 +++++----- docs/content/guides/github-app-authentication.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index fae80034..790cf5ea 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -63,7 +63,7 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 2890de4d..8e18d7e1 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -46,7 +46,7 @@ permissions: jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -92,7 +92,7 @@ changes: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -121,7 +121,7 @@ content lines stay at the same indentation level: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} @@ -233,7 +233,7 @@ You can also pass patterns via the workflow input: ```yaml jobs: Process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 with: ImportantFilePatterns: | ^src/ @@ -246,7 +246,7 @@ To disable triggering via the workflow input, pass an explicit empty string: ```yaml jobs: process: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 with: ImportantFilePatterns: '' ``` diff --git a/docs/content/guides/github-app-authentication.md b/docs/content/guides/github-app-authentication.md index 94574e24..cf35933a 100644 --- a/docs/content/guides/github-app-authentication.md +++ b/docs/content/guides/github-app-authentication.md @@ -23,7 +23,7 @@ names. Map the caller's secrets explicitly: ```yaml jobs: Process-PSModule: - uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v9 + uses: PSModule/Process-PSModule/.github/workflows/workflow.yml@v8 secrets: PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} GitHubAppClientId: ${{ secrets.SHELLY_CLIENT_ID }} From 25277e555f3ec4e1dad57a0a6960ced48352faf1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:39:19 +0200 Subject: [PATCH 08/21] Replace automatic patching with a default bump --- .../src/Get-PSModuleSettings.Helpers.psm1 | 34 ++++++- .../src/Settings.schema.json | 13 ++- .../actions/Get-PSModuleSettings/src/main.ps1 | 4 + .../Get-PSModuleSettings.Helpers.Tests.ps1 | 29 +++++- .../src/Resolve-PSModuleVersion.Helpers.psm1 | 64 +++++++++---- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 89 ++++++++++++++++--- 6 files changed, 194 insertions(+), 39 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 6f7fba66..76ad86f4 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -86,7 +86,8 @@ function Resolve-PSModulePublishSetting { .DESCRIPTION Applies Process-PSModule defaults while preserving every consumer-provided - publication and release-label mapping. + publication and release-label mapping. Rejects the removed AutoPatching + setting and invalid DefaultBump values with migration guidance. .OUTPUTS System.Management.Automation.PSCustomObject @@ -102,10 +103,39 @@ function Resolve-PSModulePublishSetting { [object] $PublishModule ) + $propertyNames = if ($null -eq $PublishModule) { + @() + } elseif ($PublishModule -is [System.Collections.IDictionary]) { + @($PublishModule.Keys) + } else { + @($PublishModule.PSObject.Properties.Name) + } + + if ($propertyNames -contains 'AutoPatching') { + throw ( + 'Publish.Module.AutoPatching was removed in Process-PSModule v9. ' + + 'Replace it with Publish.Module.DefaultBump set to patch, minor, or major.' + ) + } + + $hasDefaultBump = $propertyNames -contains 'DefaultBump' + $defaultBump = if ($hasDefaultBump) { + [string]$PublishModule.DefaultBump + } else { + 'patch' + } + $validDefaultBumps = @('patch', 'minor', 'major') + if ($validDefaultBumps -cnotcontains $defaultBump) { + throw ( + "Invalid Publish.Module.DefaultBump: [$defaultBump]. " + + "Valid values are: $($validDefaultBumps -join ', ')." + ) + } + [pscustomobject]@{ Skip = $PublishModule.Skip ?? $false AutoCleanup = $PublishModule.AutoCleanup ?? $true - AutoPatching = $PublishModule.AutoPatching ?? $true + DefaultBump = $defaultBump IncrementalPrerelease = $PublishModule.IncrementalPrerelease ?? $true DatePrereleaseFormat = $PublishModule.DatePrereleaseFormat ?? '' VersionPrefix = $PublishModule.VersionPrefix ?? 'v' diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index 7f34db64..bd0a22d0 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -138,10 +138,15 @@ "type": "boolean", "description": "When enabled (default: true), automatically cleans up old prerelease tags when merging to main or when a PR is abandoned" }, - "AutoPatching": { - "type": "boolean", - "description": "Automatically apply patches", - "default": true + "DefaultBump": { + "type": "string", + "enum": [ + "patch", + "minor", + "major" + ], + "description": "Version bump used when no configured bump label is present", + "default": "patch" }, "IncrementalPrerelease": { "type": "boolean", diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index dd13d405..9c64e2ff 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -44,6 +44,10 @@ if (![string]::IsNullOrEmpty($settingsPath) -and (Test-Path -Path $settingsPath) } LogGroup 'Validate settings against schema' { + # Validate release settings first so removed or invalid values get a + # migration-focused error instead of only a generic schema failure. + $null = Resolve-PSModulePublishSetting -PublishModule $settings.Publish.Module + $schemaPath = Join-Path $PSScriptRoot 'Settings.schema.json' if (Test-Path -Path $schemaPath) { Write-Host 'Validating settings against schema...' 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 40e86621..cd796484 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -86,7 +86,7 @@ Describe 'Resolve-PSModulePublishSetting' { It 'uses the canonical release-label defaults' { $result = Resolve-PSModulePublishSetting -PublishModule $null - $result.AutoPatching | Should -BeTrue + $result.DefaultBump | Should -BeExactly 'patch' $result.MajorLabels | Should -BeExactly 'release:major' $result.MinorLabels | Should -BeExactly 'release:minor' $result.PatchLabels | Should -BeExactly 'release:patch' @@ -96,7 +96,7 @@ Describe 'Resolve-PSModulePublishSetting' { It 'preserves consumer-controlled release settings' { $publishModule = [pscustomobject]@{ - AutoPatching = $false + DefaultBump = 'minor' MajorLabels = 'custom:major' MinorLabels = 'custom:minor' PatchLabels = 'custom:patch' @@ -106,7 +106,7 @@ Describe 'Resolve-PSModulePublishSetting' { $result = Resolve-PSModulePublishSetting -PublishModule $publishModule - $result.AutoPatching | Should -BeFalse + $result.DefaultBump | Should -BeExactly 'minor' $result.MajorLabels | Should -BeExactly 'custom:major' $result.MinorLabels | Should -BeExactly 'custom:minor' $result.PatchLabels | Should -BeExactly 'custom:patch' @@ -119,13 +119,34 @@ Describe 'Resolve-PSModulePublishSetting' { $schema = Get-Content -Path $schemaPath -Raw | ConvertFrom-Json $moduleProperties = $schema.properties.Publish.properties.Module.properties - $moduleProperties.AutoPatching.default | Should -BeTrue + $moduleProperties.DefaultBump.default | Should -BeExactly 'patch' + $moduleProperties.DefaultBump.enum | Should -Be @('patch', 'minor', 'major') + $moduleProperties.PSObject.Properties.Name | Should -Not -Contain 'AutoPatching' $moduleProperties.MajorLabels.default | Should -BeExactly 'release:major' $moduleProperties.MinorLabels.default | Should -BeExactly 'release:minor' $moduleProperties.PatchLabels.default | Should -BeExactly 'release:patch' $moduleProperties.PrereleaseLabels.default | Should -BeExactly 'release:pre-release' $moduleProperties.IgnoreLabels.default | Should -BeExactly 'release:skip' } + + It 'rejects the invalid DefaultBump value ' -ForEach @( + @{ DefaultBump = $null } + @{ DefaultBump = '' } + @{ DefaultBump = 'Patch' } + @{ DefaultBump = 'none' } + ) { + $publishModule = [pscustomobject]@{ DefaultBump = $DefaultBump } + + { Resolve-PSModulePublishSetting -PublishModule $publishModule } | + Should -Throw '*Valid values are: patch, minor, major*' + } + + It 'rejects AutoPatching with migration guidance' { + $publishModule = [pscustomobject]@{ AutoPatching = $true } + + { Resolve-PSModulePublishSetting -PublishModule $publishModule } | + Should -Throw '*AutoPatching was removed*Replace it with Publish.Module.DefaultBump*' + } } Describe 'Select-PullRequestForPush' { diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index ab9b8560..f279be05 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -19,6 +19,37 @@ ($Value -split ',') | ForEach-Object { $_.Trim() } | Where-Object { $_ } } +function Resolve-DefaultBump { + <# + .SYNOPSIS + Validates and returns the configured default version bump. + + .OUTPUTS + System.String + + .EXAMPLE + Resolve-DefaultBump -DefaultBump 'patch' + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The default bump name. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $DefaultBump + ) + + $validDefaultBumps = @('patch', 'minor', 'major') + if ($validDefaultBumps -cnotcontains $DefaultBump) { + throw ( + "Invalid Publish.Module.DefaultBump: [$DefaultBump]. " + + "Valid values are: $($validDefaultBumps -join ', ')." + ) + } + + $DefaultBump +} + function Read-ActionInput { <# .SYNOPSIS @@ -66,7 +97,7 @@ function Get-PublishConfiguration { Parses the settings JSON into a publish configuration object. .DESCRIPTION - Extracts publish module settings including auto-patching flags, version prefix, + Extracts publish module settings including the default bump, version prefix, release type, and label classification arrays. .OUTPUTS @@ -89,9 +120,10 @@ function Get-PublishConfiguration { LogGroup 'Resolve configuration' { $settings = $SettingsJson | ConvertFrom-Json $publishModule = $settings.Publish.Module + $defaultBump = Resolve-DefaultBump -DefaultBump ([string]$publishModule.DefaultBump) $config = [PSCustomObject]@{ - AutoPatching = [bool]$publishModule.AutoPatching + DefaultBump = $defaultBump IncrementalPrerelease = [bool]$publishModule.IncrementalPrerelease DatePrereleaseFormat = [string]$publishModule.DatePrereleaseFormat VersionPrefix = [string]$publishModule.VersionPrefix @@ -104,7 +136,7 @@ function Get-PublishConfiguration { Write-Host '-------------------------------------------------' Write-Host ([PSCustomObject]@{ - AutoPatching = $config.AutoPatching + DefaultBump = $config.DefaultBump IncrementalPrerelease = $config.IncrementalPrerelease DatePrereleaseFormat = $config.DatePrereleaseFormat VersionPrefix = $config.VersionPrefix @@ -129,7 +161,7 @@ function Get-GitHubPullRequest { The settings action resolves the pull request associated with a default-branch push before this action runs. When no pull request exists, a direct push or manual dispatch on the default branch still receives release context so it resolves the - default patch bump. + configured default bump. .OUTPUTS PSCustomObject with pull-request metadata, or a default-branch direct-release @@ -164,7 +196,7 @@ function Get-GitHubPullRequest { } if ($context.IsPushToDefaultBranch -or $context.IsManualDispatchToDefaultBranch) { - Write-Host 'Using direct default-branch release context with the default patch bump.' + Write-Host 'Using direct default-branch release context with the configured default bump.' return [PSCustomObject]@{ Number = $null HeadRef = $context.DefaultBranch @@ -238,6 +270,7 @@ function Resolve-ReleaseDecision { $prereleaseName = $PullRequest.HeadRef -replace '[^a-zA-Z0-9]' $labels = $PullRequest.Labels $releaseType = $Configuration.ReleaseType + $defaultBump = Resolve-DefaultBump -DefaultBump ([string]$Configuration.DefaultBump) $validReleaseTypes = @('Release', 'Prerelease', 'None') if ([string]::IsNullOrWhiteSpace($releaseType)) { @@ -285,20 +318,16 @@ function Resolve-ReleaseDecision { $majorRelease = $majorLabels.Count -eq 1 $minorRelease = $minorLabels.Count -eq 1 - $isDirectStableRelease = $createRelease -and $PullRequest.IsDirectRelease - $patchRelease = $patchLabels.Count -eq 1 -or ( - -not $majorRelease -and - -not $minorRelease -and - ($Configuration.AutoPatching -or $isDirectStableRelease) - ) - $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease + $patchRelease = $patchLabels.Count -eq 1 - if (-not $hasVersionBump) { - Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version without publishing.' - $patchRelease = $true - $hasVersionBump = $true - $shouldPublish = $false + if (-not $majorRelease -and -not $minorRelease -and -not $patchRelease) { + switch -CaseSensitive ($defaultBump) { + 'major' { $majorRelease = $true } + 'minor' { $minorRelease = $true } + 'patch' { $patchRelease = $true } + } } + $hasVersionBump = $majorRelease -or $minorRelease -or $patchRelease if ($ignoreRelease) { $createRelease = $false @@ -316,6 +345,7 @@ function Resolve-ReleaseDecision { ShouldPublish = $shouldPublish CreateRelease = $createRelease CreatePrerelease = $createPrerelease + DefaultBump = $defaultBump Major = $majorRelease Minor = $minorRelease Patch = $patchRelease 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 2b5cf1ee..526ff9dc 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -2,6 +2,10 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingCmdletAliases', 'Context', + Justification = 'Context is part of the Pester DSL, not the Get-Context alias.' +)] [CmdletBinding()] param() @@ -17,9 +21,9 @@ BeforeAll { [CmdletBinding()] [OutputType([PSCustomObject])] param( - # Whether an unlabeled pull request is treated as a patch. + # The bump used when no configured bump label is present. [Parameter()] - [bool] $AutoPatching = $true, + [string] $DefaultBump = 'patch', # Whether prereleases get an incrementing number suffix. [Parameter()] @@ -55,7 +59,7 @@ BeforeAll { ) [PSCustomObject]@{ - AutoPatching = $AutoPatching + DefaultBump = $DefaultBump IncrementalPrerelease = $IncrementalPrerelease DatePrereleaseFormat = $DatePrereleaseFormat VersionPrefix = $VersionPrefix @@ -107,6 +111,38 @@ BeforeAll { } Describe 'Resolve-PSModuleVersion' { + Describe 'Get-PublishConfiguration' { + It 'maps DefaultBump and configured labels from settings JSON' { + $settings = @{ + Publish = @{ + Module = @{ + DefaultBump = 'major' + IncrementalPrerelease = $true + DatePrereleaseFormat = '' + VersionPrefix = 'v' + ReleaseType = 'Release' + IgnoreLabels = 'custom:skip' + MajorLabels = 'custom:major' + MinorLabels = 'custom:minor' + PatchLabels = 'custom:patch' + } + } + } | ConvertTo-Json -Depth 5 + + $configuration = Get-PublishConfiguration -SettingsJson $settings + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @('custom:minor') + } + $result = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest + + $configuration.DefaultBump | Should -BeExactly 'major' + $configuration.MinorLabels | Should -Be @('custom:minor') + $result.MinorRelease | Should -BeTrue + $result.MajorRelease | Should -BeFalse + } + } + Describe 'ConvertFrom-GitHubReleaseJson' { Context 'ConvertFrom-GitHubReleaseJson - repository without releases' { It 'ConvertFrom-GitHubReleaseJson - returns an empty array for an empty JSON array' { @@ -542,20 +578,24 @@ Describe 'Resolve-PSModuleVersion' { } Describe 'Resolve-ReleaseDecision' { - It 'uses the default patch bump for a direct stable release' { - $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -AutoPatching $false) ` + It 'uses the configured bump for a direct stable release' -ForEach @( + @{ DefaultBump = 'patch'; Flag = 'PatchRelease' } + @{ DefaultBump = 'minor'; Flag = 'MinorRelease' } + @{ DefaultBump = 'major'; Flag = 'MajorRelease' } + ) { + $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -DefaultBump $DefaultBump) ` -PullRequest ([pscustomobject]@{ HeadRef = 'main'; Labels = @(); IsDirectRelease = $true }) $result.ShouldPublish | Should -BeTrue - $result.PatchRelease | Should -BeTrue + $result.$Flag | Should -BeTrue } - It 'does not publish an unlabeled prerelease when AutoPatching is disabled' { - $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -AutoPatching $false -ReleaseType Prerelease) ` + It 'uses the configured default bump for a prerelease mode label' { + $result = Resolve-ReleaseDecision -Configuration (Get-TestConfiguration -DefaultBump 'major' -ReleaseType Prerelease) ` -PullRequest ([pscustomobject]@{ HeadRef = 'feature'; Labels = @() }) - $result.ShouldPublish | Should -BeFalse - $result.PatchRelease | Should -BeTrue + $result.ShouldPublish | Should -BeTrue + $result.MajorRelease | Should -BeTrue } It 'does not validate cleanup-only pull request labels' { @@ -580,7 +620,7 @@ Describe 'Resolve-PSModuleVersion' { } | Should -Throw '*ignore label cannot be combined*' } - It 'honors the canonical default' -ForEach @( + It 'honors the canonical label' -ForEach @( @{ Bump = 'Major'; Label = 'release:major'; Flag = 'MajorRelease' } @{ Bump = 'Minor'; Label = 'release:minor'; Flag = 'MinorRelease' } @{ Bump = 'Patch'; Label = 'release:patch'; Flag = 'PatchRelease' } @@ -614,8 +654,21 @@ Describe 'Resolve-PSModuleVersion' { $result.ShouldPublish | Should -BeTrue } + It 'gives an explicit bump label precedence over DefaultBump' { + $configuration = Get-TestConfiguration -DefaultBump 'major' + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @('release:minor') + } + + $result = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest + + $result.MinorRelease | Should -BeTrue + $result.MajorRelease | Should -BeFalse + } + It 'honors a configured ignore label' { - $configuration = Get-TestConfiguration -IgnoreLabels @('custom:skip') + $configuration = Get-TestConfiguration -DefaultBump 'major' -IgnoreLabels @('custom:skip') $pullRequest = [pscustomobject]@{ HeadRef = 'main' Labels = @('custom:skip') @@ -624,6 +677,18 @@ Describe 'Resolve-PSModuleVersion' { $result = Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest $result.ShouldPublish | Should -BeFalse + $result.CreateRelease | Should -BeFalse + } + + It 'rejects an invalid DefaultBump with valid values' { + $configuration = Get-TestConfiguration -DefaultBump 'none' + $pullRequest = [pscustomobject]@{ + HeadRef = 'main' + Labels = @() + } + + { Resolve-ReleaseDecision -Configuration $configuration -PullRequest $pullRequest } | + Should -Throw '*Valid values are: patch, minor, major*' } } } From 27dedd632db74cd4a6a2407461e31bc7aa3d94de Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 2 Sep 2026 11:08:24 +0200 Subject: [PATCH 09/21] fix: honour the merged PR version label on a default-branch dispatch A workflow_dispatch on the default branch is the documented recovery route for a failed or cancelled release run. Pull request association was gated on $isPush, so a dispatch resolved no pull request, saw no version label, and silently applied the AutoPatching patch fallback. Widen the gate to cover a manual dispatch on the default branch, so the same association and selection path runs for the same merge commit. Also fail loudly when a merged default-branch pull request is associated with the commit but does not match it, instead of falling back to a patch bump. A wrong version published to the PowerShell Gallery cannot be reclaimed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 97 +++++++++++++++++++ .../actions/Get-PSModuleSettings/src/main.ps1 | 37 ++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index ef5aef29..742c25dd 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -109,6 +109,103 @@ function Select-PullRequestForPush { Select-Object -First 1 } +function Test-ShouldResolveAssociatedPullRequest { + <# + .SYNOPSIS + Decides whether a commit's associated pull request must be resolved from the GitHub API. + + .DESCRIPTION + A push to any branch resolves its associated pull request 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 + null and silently downgraded a labelled Major or Minor release to a Patch bump. + + .OUTPUTS + Boolean. True when the association lookup must run. + + .EXAMPLE + Test-ShouldResolveAssociatedPullRequest -IsPush $false -IsManualDispatchToDefaultBranch $true -CommitSha 'abc123' + + Returns $true, because a default-branch dispatch releases the same commit a push would. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + # Whether the workflow was triggered by a push event. + [Parameter()] + [bool] $IsPush, + + # Whether the workflow was manually dispatched against the default branch. + [Parameter()] + [bool] $IsManualDispatchToDefaultBranch, + + # The commit the workflow is resolving a release for. + [Parameter()] + [string] $CommitSha + ) + + ($IsPush -or $IsManualDispatchToDefaultBranch) -and -not [string]::IsNullOrWhiteSpace($CommitSha) +} + +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 Get-FilesFromGitTree { <# .SYNOPSIS diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index da1ad72b..b3b61531 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -242,12 +242,20 @@ LogGroup 'Calculate Job Run Conditions:' { $isManualDispatchToDefaultBranch = $isManualDispatch -and $workflowRef -eq $defaultBranch $pullRequest = $eventData.PullRequest - if ($isPush -and $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. + $shouldResolvePullRequest = Test-ShouldResolveAssociatedPullRequest -IsPush $isPush ` + -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` + -CommitSha $commitSha + if ($shouldResolvePullRequest) { LogGroup "Resolve pull request for commit [$commitSha]" { $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) + $associatedPullRequests = @($response.Response | Where-Object { $null -ne $_ }) $pullRequest = Select-PullRequestForPush -PullRequest $associatedPullRequests ` -DefaultBranch $defaultBranch ` -CommitSha $commitSha @@ -255,6 +263,31 @@ LogGroup 'Calculate Job Run Conditions:' { if ($pullRequest) { Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$commitSha]." } else { + # Distinguish 'no pull request carries release intent' from 'a merged default-branch + # pull request exists but was not selected'. The first is a legitimate direct push to + # the default branch, which the direct-release path handles with the default patch + # bump. The second means the commit carries a version label that would be discarded, + # and a wrong version published to the PowerShell Gallery cannot be reclaimed, so + # fail loudly instead. Only release-bearing events are checked; a push to a feature + # branch has no release to get wrong. + $isReleaseEvent = $isPushToDefaultBranch -or $isManualDispatchToDefaultBranch + $discardedPullRequests = if ($isReleaseEvent) { + @(Get-DiscardedReleasePullRequest -PullRequest $associatedPullRequests ` + -DefaultBranch $defaultBranch ` + -CommitSha $commitSha) + } else { + @() + } + if ($discardedPullRequests.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: $($discardedPullRequests -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]." } } From 189d3eb1e7f1992ce65604669f8c25cbd7280216 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Wed, 2 Sep 2026 11:15:36 +0200 Subject: [PATCH 10/21] refactor: make release pull request resolution unit-testable Extract the gate, selection, and no-pull-request decision from main.ps1 into Resolve-ReleasePullRequest, with the GitHub lookup injected as a script block. The composed logic that produced the wrong version is now covered directly rather than re-implemented in a test. Add Get-DiscardedReleasePullRequest so a merged default-branch pull request that does not match the released commit fails the run instead of silently applying a patch bump. Open pull requests and non-default-branch merges are ignored, so a direct push and a feature-branch push are unaffected. Each new test was verified to fail against the specific defect it guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 145 ++++++++---- .../actions/Get-PSModuleSettings/src/main.ps1 | 55 ++--- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 208 ++++++++++++++++++ .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 81 +++++++ 4 files changed, 409 insertions(+), 80 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 742c25dd..4997b040 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -109,45 +109,6 @@ function Select-PullRequestForPush { Select-Object -First 1 } -function Test-ShouldResolveAssociatedPullRequest { - <# - .SYNOPSIS - Decides whether a commit's associated pull request must be resolved from the GitHub API. - - .DESCRIPTION - A push to any branch resolves its associated pull request 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 - null and silently downgraded a labelled Major or Minor release to a Patch bump. - - .OUTPUTS - Boolean. True when the association lookup must run. - - .EXAMPLE - Test-ShouldResolveAssociatedPullRequest -IsPush $false -IsManualDispatchToDefaultBranch $true -CommitSha 'abc123' - - Returns $true, because a default-branch dispatch releases the same commit a push would. - #> - [CmdletBinding()] - [OutputType([bool])] - param( - # Whether the workflow was triggered by a push event. - [Parameter()] - [bool] $IsPush, - - # Whether the workflow was manually dispatched against the default branch. - [Parameter()] - [bool] $IsManualDispatchToDefaultBranch, - - # The commit the workflow is resolving a release for. - [Parameter()] - [string] $CommitSha - ) - - ($IsPush -or $IsManualDispatchToDefaultBranch) -and -not [string]::IsNullOrWhiteSpace($CommitSha) -} - function Get-DiscardedReleasePullRequest { <# .SYNOPSIS @@ -206,6 +167,112 @@ function Get-DiscardedReleasePullRequest { } } +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 b3b61531..5a66c41c 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -247,51 +247,24 @@ LogGroup 'Calculate Job Run Conditions:' { # 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. - $shouldResolvePullRequest = Test-ShouldResolveAssociatedPullRequest -IsPush $isPush ` - -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` - -CommitSha $commitSha - if ($shouldResolvePullRequest) { - LogGroup "Resolve pull request for commit [$commitSha]" { + $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 | Where-Object { $null -ne $_ }) - $pullRequest = Select-PullRequestForPush -PullRequest $associatedPullRequests ` - -DefaultBranch $defaultBranch ` - -CommitSha $commitSha - - if ($pullRequest) { - Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$commitSha]." - } else { - # Distinguish 'no pull request carries release intent' from 'a merged default-branch - # pull request exists but was not selected'. The first is a legitimate direct push to - # the default branch, which the direct-release path handles with the default patch - # bump. The second means the commit carries a version label that would be discarded, - # and a wrong version published to the PowerShell Gallery cannot be reclaimed, so - # fail loudly instead. Only release-bearing events are checked; a push to a feature - # branch has no release to get wrong. - $isReleaseEvent = $isPushToDefaultBranch -or $isManualDispatchToDefaultBranch - $discardedPullRequests = if ($isReleaseEvent) { - @(Get-DiscardedReleasePullRequest -PullRequest $associatedPullRequests ` - -DefaultBranch $defaultBranch ` - -CommitSha $commitSha) - } else { - @() - } - if ($discardedPullRequests.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: $($discardedPullRequests -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]." - } + $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 = @() }) From 8add11c4cb83d6b320b654c9d62ded0378d7f70e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 18:29:48 +0200 Subject: [PATCH 11/21] Use the canonical prerelease qualifier --- .../src/Get-PSModuleSettings.Helpers.psm1 | 2 +- .github/actions/Get-PSModuleSettings/src/Settings.schema.json | 2 +- .../tests/Get-PSModuleSettings.Helpers.Tests.ps1 | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 76ad86f4..ad8fb6b3 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -143,7 +143,7 @@ function Resolve-PSModulePublishSetting { MinorLabels = $PublishModule.MinorLabels ?? 'release:minor' PatchLabels = $PublishModule.PatchLabels ?? 'release:patch' IgnoreLabels = $PublishModule.IgnoreLabels ?? 'release:skip' - PrereleaseLabels = $PublishModule.PrereleaseLabels ?? 'release:pre-release' + PrereleaseLabels = $PublishModule.PrereleaseLabels ?? 'release:prerelease' UsePRTitleAsReleaseName = $PublishModule.UsePRTitleAsReleaseName ?? $false UsePRBodyAsReleaseNotes = $PublishModule.UsePRBodyAsReleaseNotes ?? $true UsePRTitleAsNotesHeading = $PublishModule.UsePRTitleAsNotesHeading ?? $true diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index bd0a22d0..067cdc4e 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -183,7 +183,7 @@ "PrereleaseLabels": { "type": "string", "description": "Comma-separated labels that trigger a prerelease", - "default": "release:pre-release" + "default": "release:prerelease" }, "UsePRTitleAsReleaseName": { "type": "boolean", 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 cd796484..1344dc87 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -90,7 +90,7 @@ Describe 'Resolve-PSModulePublishSetting' { $result.MajorLabels | Should -BeExactly 'release:major' $result.MinorLabels | Should -BeExactly 'release:minor' $result.PatchLabels | Should -BeExactly 'release:patch' - $result.PrereleaseLabels | Should -BeExactly 'release:pre-release' + $result.PrereleaseLabels | Should -BeExactly 'release:prerelease' $result.IgnoreLabels | Should -BeExactly 'release:skip' } @@ -125,7 +125,7 @@ Describe 'Resolve-PSModulePublishSetting' { $moduleProperties.MajorLabels.default | Should -BeExactly 'release:major' $moduleProperties.MinorLabels.default | Should -BeExactly 'release:minor' $moduleProperties.PatchLabels.default | Should -BeExactly 'release:patch' - $moduleProperties.PrereleaseLabels.default | Should -BeExactly 'release:pre-release' + $moduleProperties.PrereleaseLabels.default | Should -BeExactly 'release:prerelease' $moduleProperties.IgnoreLabels.default | Should -BeExactly 'release:skip' } From 2646335170079045980aacc38c3ed0857c19c45f Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 12:47:40 +0100 Subject: [PATCH 12/21] Guard dispatch association through composed version tests --- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 52 +++++++++ .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 109 ++++++++++++------ 2 files changed, 124 insertions(+), 37 deletions(-) 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 df106eb5..bfba3009 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -278,6 +278,58 @@ Describe 'Resolve-ReleasePullRequest' { $result.Resolved | Should -BeTrue $result.PullRequest | Should -BeNullOrEmpty } + + It 'rejects an unmatched merged pull request for a default-branch push' { + $otherMerge = [pscustomobject]@{ + Number = 412 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-09-01T00:00:00Z' + merge_commit_sha = 'merge-of-412' + } + $parameters = @{ + EventName = 'push' + CommitSha = 'released-sha' + DefaultBranch = 'main' + IsPushToDefaultBranch = $true + GetAssociatedPullRequest = { @($otherMerge) }.GetNewClosure() + } + + { Resolve-ReleasePullRequest @parameters } | Should -Throw '*#412*merge-of-412*cannot be reclaimed*' + } + + It 'uses the exact merge match when unrelated merged pull requests also claim the commit' { + $otherMerge = [pscustomobject]@{ + Number = 412 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-09-03T00:00:00Z' + merge_commit_sha = 'merge-of-412' + } + $matchingPullRequest = $script:mergedPullRequest + $parameters = @{ + EventName = 'workflow_dispatch' + CommitSha = 'released-sha' + DefaultBranch = 'main' + IsManualDispatchToDefaultBranch = $true + GetAssociatedPullRequest = { @($otherMerge, $matchingPullRequest) }.GetNewClosure() + } + + $result = Resolve-ReleasePullRequest @parameters + + $result.PullRequest.Number | Should -Be 64 + $result.PullRequest.Labels.Name | Should -Be @('minor') + } + + It 'propagates an association lookup failure instead of treating it as a direct release' { + $parameters = @{ + EventName = 'workflow_dispatch' + CommitSha = 'released-sha' + DefaultBranch = 'main' + IsManualDispatchToDefaultBranch = $true + GetAssociatedPullRequest = { throw 'Commit association lookup failed.' } + } + + { Resolve-ReleasePullRequest @parameters } | Should -Throw '*Commit association lookup failed*' + } } Describe 'Get-DiscardedReleasePullRequest' { 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 80424da2..2635b9fe 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -2,11 +2,16 @@ 'PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingCmdletAliases', 'Context', + Justification = 'Context is a Pester test block, not an implicit call to Get-Context.' +)] [CmdletBinding()] param() BeforeAll { Import-Module -Name 'PSModule' -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../../Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1') -Force Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/Resolve-PSModuleVersion.Helpers.psm1') -Force function Get-TestConfiguration { @@ -574,45 +579,75 @@ 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 + It 'resolves for a with