From 4fb660b58e0a12b742646b35832e8ea1e3a549a8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:40:07 +0200 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 }}