From 94674b79abdb3f1cb6458d7da3328f6b7682d8e5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 08:49:18 +0200 Subject: [PATCH 01/14] Normalize release event context Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 122 +++++++++++ .../actions/Get-PSModuleSettings/src/main.ps1 | 190 +++++++++++++----- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 136 +++++++++++++ .../src/Resolve-PSModuleVersion.Helpers.psm1 | 104 +++++++--- .../Resolve-PSModuleVersion/src/main.ps1 | 2 +- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 79 ++++++++ 6 files changed, 555 insertions(+), 78 deletions(-) create mode 100644 .github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 create mode 100644 .github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 new file mode 100644 index 00000000..4b2775e3 --- /dev/null +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -0,0 +1,122 @@ +function Resolve-WorkflowEventRouting { + <# + .SYNOPSIS + Resolves release and execution routing from normalized GitHub event state. + #> + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter(Mandatory)] + [string] $EventName, + + [Parameter()] + [string] $EventAction, + + [Parameter()] + [bool] $PullRequestIsMerged, + + [Parameter()] + [bool] $IsTargetDefaultBranch, + + [Parameter()] + [bool] $IsPushToDefaultBranch, + + [Parameter()] + [bool] $IsManualDispatchToDefaultBranch, + + [Parameter()] + [bool] $HasImportantChanges, + + [Parameter()] + [bool] $HasPrereleaseLabel + ) + + $isPR = $EventName -eq 'pull_request' + $isPush = $EventName -eq 'push' + $isManualDispatch = $EventName -eq 'workflow_dispatch' + $isOpenOrUpdatedPR = $isPR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') + $isOpenOrLabeledPR = $isPR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled') + $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 + ) + + [pscustomobject]@{ + IsPR = $isPR + IsPush = $isPush + IsManualDispatch = $isManualDispatch + IsOpenOrUpdatedPR = $isOpenOrUpdatedPR + IsOpenOrLabeledPR = $isOpenOrLabeledPR + IsClosedPR = $isClosedPR + IsAbandonedPR = $isAbandonedPR + IsMergedPR = $isMergedPR + IsTargetDefaultBranch = $IsTargetDefaultBranch + IsPushToDefaultBranch = $IsPushToDefaultBranch + IsManualDispatchToDefaultBranch = $IsManualDispatchToDefaultBranch + ShouldPrerelease = $shouldPrerelease + ReleaseType = if ($shouldRelease) { + 'Release' + } elseif ($shouldPrerelease) { + 'Prerelease' + } else { + 'None' + } + ShouldRunBuildTest = (-not $isClosedPR) -and $HasImportantChanges + ShouldCleanupEvent = $isClosedPR + } +} + +function Select-PullRequestForPush { + <# + .SYNOPSIS + Selects the merged default-branch pull request associated with a pushed commit. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + Justification = 'Parameter is used inside a Sort-Object script block.')] + [CmdletBinding()] + [OutputType([PSCustomObject])] + param( + [Parameter()] + [object[]] $PullRequest, + + [Parameter(Mandatory)] + [string] $DefaultBranch, + + [Parameter(Mandatory)] + [string] $CommitSha + ) + + $PullRequest | + Where-Object { + $_.Base.Ref -eq $DefaultBranch -and + -not [string]::IsNullOrWhiteSpace($_.merged_at) -and + $_.merge_commit_sha -eq $CommitSha + } | + Sort-Object -Property @{ Expression = { $_.merged_at }; Descending = $true } | + Select-Object -First 1 +} + +function Get-FilesFromGitTree { + <# + .SYNOPSIS + Returns the files contained in a complete Git tree response. + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $Tree + ) + + if ($Tree.truncated) { + throw 'Cannot determine changed files because the Git tree response was truncated.' + } + + @($Tree.tree | + Where-Object { $_.type -eq 'blob' } | + Select-Object -ExpandProperty path) +} diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 36aeba1a..5907b884 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -1,4 +1,5 @@ 'powershell-yaml', 'Hashtable' | Install-PSResource -Repository PSGallery -TrustRepository +Import-Module -Name "$PSScriptRoot/Get-PSModuleSettings.Helpers.psm1" -Force $name = $env:PSMODULE_GET_SETTINGS_INPUT_Name $settingsPath = $env:PSMODULE_GET_SETTINGS_INPUT_SettingsPath @@ -226,43 +227,100 @@ LogGroup 'Calculate Job Run Conditions:' { $eventData | ConvertTo-Json -Depth 10 | Out-String } + $eventName = $env:GITHUB_EVENT_NAME + $isPush = $eventName -eq 'push' + $isManualDispatch = $eventName -eq 'workflow_dispatch' + $defaultBranch = $eventData.Repository.default_branch $pullRequestAction = $eventData.Action + $commitSha = if ($isPush) { $eventData.After ?? $env:GITHUB_SHA } else { $env:GITHUB_SHA } + $pushBranch = if ($isPush) { $eventData.Ref -replace '^refs/heads/', '' } else { '' } + $workflowRef = if ($isPush) { $pushBranch } else { $env:GITHUB_REF_NAME } + $isPushToDefaultBranch = $isPush -and $pushBranch -eq $defaultBranch + $isManualDispatchToDefaultBranch = $isManualDispatch -and $workflowRef -eq $defaultBranch $pullRequest = $eventData.PullRequest - $pullRequestIsMerged = $pullRequest.Merged - $targetBranch = $pullRequest.Base.Ref - $defaultBranch = $eventData.Repository.default_branch + + if ($isPush -and $commitSha) { + LogGroup "Resolve pull request for commit [$commitSha]" { + $owner = $env:GITHUB_REPOSITORY_OWNER + $repo = $env:GITHUB_REPOSITORY_NAME + $response = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/commits/$commitSha/pulls" -Method GET + $associatedPullRequests = @($response.Response) + $pullRequest = Select-PullRequestForPush -PullRequest $associatedPullRequests ` + -DefaultBranch $defaultBranch ` + -CommitSha $commitSha + + if ($pullRequest) { + Write-Host "Resolved pull request #$($pullRequest.Number) from commit [$commitSha]." + } else { + Write-Host "::notice::No pull request is associated with commit [$commitSha]." + } + } + } + + $pullRequestIsMerged = if ($null -eq $pullRequest) { + $false + } elseif ($null -ne $pullRequest.Merged) { + [bool]$pullRequest.Merged + } else { + -not [string]::IsNullOrWhiteSpace($pullRequest.merged_at) + } + $targetBranch = if ($pullRequest) { $pullRequest.Base.Ref } elseif ($isPush) { $pushBranch } else { $workflowRef } $isTargetDefaultBranch = $targetBranch -eq $defaultBranch + $pullRequestContext = if ($pullRequest) { + [pscustomobject]@{ + Number = $pullRequest.Number + Title = $pullRequest.Title + Body = $pullRequest.Body + HeadRef = $pullRequest.Head.Ref + BaseRef = $pullRequest.Base.Ref + Labels = @($pullRequest.Labels.Name) + Merged = $pullRequestIsMerged + MergeCommitSha = $pullRequest.merge_commit_sha + HtmlUrl = $pullRequest.html_url + } + } else { + $null + } + + $settings | Add-Member -MemberType NoteProperty -Name Context -Value ([pscustomobject]@{ + EventName = $eventName + EventAction = $pullRequestAction + CommitSha = $commitSha + Ref = if ($isPush) { $eventData.Ref } else { $env:GITHUB_REF } + DefaultBranch = $defaultBranch + IsPushToDefaultBranch = $isPushToDefaultBranch + IsManualDispatchToDefaultBranch = $isManualDispatchToDefaultBranch + PullRequest = $pullRequestContext + }) -Force Write-Host 'GitHub event inputs:' [pscustomobject]@{ - GITHUB_EVENT_NAME = $env:GITHUB_EVENT_NAME + GITHUB_EVENT_NAME = $eventName GITHUB_EVENT_ACTION = $pullRequestAction GITHUB_EVENT_PULL_REQUEST_MERGED = $pullRequestIsMerged + CommitSha = $commitSha + PushBranch = $pushBranch TargetBranch = $targetBranch DefaultBranch = $defaultBranch IsTargetDefaultBranch = $isTargetDefaultBranch + IsPushToDefaultBranch = $isPushToDefaultBranch + IsManualDispatchToDefaultBranch = $isManualDispatchToDefaultBranch + AssociatedPullRequest = $pullRequestContext.Number } | Format-List | Out-String - $isPR = $env:GITHUB_EVENT_NAME -eq 'pull_request' - $isOpenOrUpdatedPR = $isPR -and $pullRequestAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') - $isAbandonedPR = $isPR -and $pullRequestAction -eq 'closed' -and $pullRequestIsMerged -ne $true - $isMergedPR = $isPR -and $pullRequestAction -eq 'closed' -and $pullRequestIsMerged -eq $true - $isNotAbandonedPR = -not $isAbandonedPR - # Check if a prerelease label exists on the PR $prereleaseLabels = $settings.Publish.Module.PrereleaseLabels -split ',' | ForEach-Object { $_.Trim() } - $prLabels = @($pullRequest.labels.name) + $prLabels = @($pullRequestContext.Labels) $hasPrereleaseLabel = ($prLabels | Where-Object { $prereleaseLabels -contains $_ }).Count -gt 0 - $isOpenOrLabeledPR = $isPR -and $pullRequestAction -in @('opened', 'reopened', 'synchronize', 'labeled') # Check if important files have changed in the PR # Important files are determined by the configured ImportantFilePatterns setting $hasImportantChanges = $false - if ($isPR -and $pullRequest.Number) { + if ($pullRequestContext.Number) { LogGroup 'Check for Important File Changes' { $owner = $env:GITHUB_REPOSITORY_OWNER $repo = $env:GITHUB_REPOSITORY_NAME - $prNumber = $pullRequest.Number + $prNumber = $pullRequestContext.Number Write-Host "Fetching changed files for PR #$prNumber..." $changedFiles = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/pulls/$prNumber/files" -Method GET | @@ -332,38 +390,72 @@ If you believe this is incorrect, please verify that your changes are in the cor } } } + } elseif ($isPushToDefaultBranch) { + LogGroup 'Check for Important File Changes' { + $beforeCommitSha = $eventData.Before + $owner = $env:GITHUB_REPOSITORY_OWNER + $repo = $env:GITHUB_REPOSITORY_NAME + if ([string]::IsNullOrWhiteSpace($beforeCommitSha) -or $beforeCommitSha -match '^0+$') { + Write-Host "Fetching files for the initial push commit [$commitSha]..." + $commit = (Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/commits/$commitSha" -Method GET).Response + $treeSha = $commit.tree.sha + if ([string]::IsNullOrWhiteSpace($treeSha)) { + throw "Cannot determine changed files because commit [$commitSha] has no tree." + } + + $tree = (Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/git/trees/$treeSha?recursive=1" -Method GET).Response + $changedFiles = Get-FilesFromGitTree -Tree $tree + } else { + Write-Host "Fetching changed files between [$beforeCommitSha] and [$commitSha]..." + $changedFiles = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/compare/$beforeCommitSha...$commitSha" -Method GET | + Select-Object -ExpandProperty Response | + Select-Object -ExpandProperty files | + Select-Object -ExpandProperty filename + } + + Write-Host "Changed files ($($changedFiles.Count)):" + $changedFiles | ForEach-Object { Write-Host " - $_" } + + foreach ($file in $changedFiles) { + foreach ($pattern in $settings.ImportantFilePatterns) { + if ($file -match $pattern) { + $hasImportantChanges = $true + Write-Host "Important file changed: [$file] (matches pattern: $pattern)" + break + } + } + if ($hasImportantChanges) { break } + } + } } else { - # Not a PR event or no PR number - consider as having important changes (e.g., workflow_dispatch, schedule) + # Manual dispatch and schedule runs retain their existing build/test behavior. $hasImportantChanges = $true - Write-Host 'Not a PR event or missing PR number - treating as having important changes' + Write-Host 'Non-PR event - treating as having important changes' } - # Prerelease requires both: prerelease label AND important file changes - # No point creating a prerelease if only non-module files changed - $shouldPrerelease = $isOpenOrLabeledPR -and $hasPrereleaseLabel -and $hasImportantChanges - - # Determine ReleaseType - what type of release to create - # Values: 'Release', 'Prerelease', 'None' - # Release only happens when important files changed (actual module code/docs) - # Merged PRs without important changes should only trigger cleanup, not a new release - $releaseType = if ($isMergedPR -and $isTargetDefaultBranch -and $hasImportantChanges) { - 'Release' - } elseif ($shouldPrerelease) { - 'Prerelease' - } else { - 'None' - } + $routing = Resolve-WorkflowEventRouting -EventName $eventName ` + -EventAction $pullRequestAction ` + -PullRequestIsMerged $pullRequestIsMerged ` + -IsTargetDefaultBranch $isTargetDefaultBranch ` + -IsPushToDefaultBranch $isPushToDefaultBranch ` + -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` + -HasImportantChanges $hasImportantChanges ` + -HasPrereleaseLabel $hasPrereleaseLabel + $releaseType = $routing.ReleaseType [pscustomobject]@{ - isPR = $isPR - isOpenOrUpdatedPR = $isOpenOrUpdatedPR - isOpenOrLabeledPR = $isOpenOrLabeledPR - isAbandonedPR = $isAbandonedPR - isMergedPR = $isMergedPR - isNotAbandonedPR = $isNotAbandonedPR - isTargetDefaultBranch = $isTargetDefaultBranch + isPR = $routing.IsPR + isOpenOrUpdatedPR = $routing.IsOpenOrUpdatedPR + isOpenOrLabeledPR = $routing.IsOpenOrLabeledPR + isClosedPR = $routing.IsClosedPR + isAbandonedPR = $routing.IsAbandonedPR + isMergedPR = $routing.IsMergedPR + isPush = $routing.IsPush + isManualDispatch = $routing.IsManualDispatch + isPushToDefaultBranch = $routing.IsPushToDefaultBranch + isTargetDefaultBranch = $routing.IsTargetDefaultBranch hasPrereleaseLabel = $hasPrereleaseLabel - shouldPrerelease = $shouldPrerelease + shouldPrerelease = $routing.ShouldPrerelease ReleaseType = $releaseType HasImportantChanges = $hasImportantChanges } | Format-List | Out-String @@ -531,24 +623,14 @@ $settings.Test.Module | Add-Member -MemberType NoteProperty -Name Suites -Value # Calculate job-specific conditions and add to settings LogGroup 'Calculate Job Run Conditions:' { - # Calculate if prereleases should be cleaned up: - # True if (Release, merged PR to default branch, or Abandoned PR) AND user has AutoCleanup enabled (defaults to true) - # Even if no important files changed, we still want to cleanup prereleases when merging to default branch - $isReleaseOrMergedOrAbandoned = ( - ($releaseType -eq 'Release') -or - ($isMergedPR -and $isTargetDefaultBranch) -or - $isAbandonedPR - ) - $shouldAutoCleanup = $isReleaseOrMergedOrAbandoned -and ($settings.Publish.Module.AutoCleanup -eq $true) + $shouldAutoCleanup = $routing.ShouldCleanupEvent -and ($settings.Publish.Module.AutoCleanup -eq $true) # Update Publish.Module with computed release values $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name ReleaseType -Value $releaseType -Force $settings.Publish.Module.AutoCleanup = $shouldAutoCleanup - # For open PRs, we only want to run build/test stages if important files changed. - # For merged PRs, workflow_dispatch, schedule - $hasImportantChanges is already true. - # Note: $shouldPrerelease already requires $hasImportantChanges, so no separate check needed. - $shouldRunBuildTest = $isNotAbandonedPR -and $hasImportantChanges + # Closed PR events are cleanup-only. Other events run build/test only for important changes. + $shouldRunBuildTest = $routing.ShouldRunBuildTest # Check if setup/teardown scripts exist in the repository $hasBeforeAllScript = Test-Path -Path 'tests/BeforeAll.ps1' @@ -607,8 +689,8 @@ LogGroup 'Calculate Job Run Conditions:' { $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Desired -Value (($releaseType -ne 'None') -or $shouldAutoCleanup) -Force $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Enabled -Value (($releaseType -ne 'None') -or $shouldAutoCleanup) -Force $settings.Publish | Add-Member -MemberType NoteProperty -Name Site -Value ([pscustomobject]@{ - Desired = $isMergedPR -and $isTargetDefaultBranch -and $hasImportantChanges - Enabled = $isMergedPR -and $isTargetDefaultBranch -and $hasImportantChanges + Desired = $releaseType -eq 'Release' + Enabled = $releaseType -eq 'Release' }) -Force $settings | Add-Member -MemberType NoteProperty -Name HasImportantChanges -Value $hasImportantChanges diff --git a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 new file mode 100644 index 00000000..b1abdcc6 --- /dev/null +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -0,0 +1,136 @@ +BeforeAll { + Import-Module "$PSScriptRoot/../src/Get-PSModuleSettings.Helpers.psm1" -Force +} + +Describe 'Resolve-WorkflowEventRouting' { + It 'routes an associated push to the default branch to a stable release' { + $result = Resolve-WorkflowEventRouting -EventName push ` + -IsTargetDefaultBranch $true ` + -IsPushToDefaultBranch $true ` + -HasImportantChanges $true + + $result.ReleaseType | Should -Be 'Release' + $result.ShouldRunBuildTest | Should -BeTrue + $result.ShouldCleanupEvent | Should -BeFalse + } + + It 'routes a direct push to the default branch to a stable release' { + $result = Resolve-WorkflowEventRouting -EventName push ` + -IsTargetDefaultBranch $true ` + -IsPushToDefaultBranch $true ` + -HasImportantChanges $true + + $result.ReleaseType | Should -Be 'Release' + } + + It 'does not publish a documentation-only default-branch push' { + $result = Resolve-WorkflowEventRouting -EventName push ` + -IsTargetDefaultBranch $true ` + -IsPushToDefaultBranch $true ` + -HasImportantChanges $false + + $result.ReleaseType | Should -Be 'None' + } + + It 'routes a default-branch manual dispatch to a stable release' { + $result = Resolve-WorkflowEventRouting -EventName workflow_dispatch ` + -IsTargetDefaultBranch $true ` + -IsManualDispatchToDefaultBranch $true ` + -HasImportantChanges $true + + $result.ReleaseType | Should -Be 'Release' + } + + It 'routes a labeled open PR with important changes to a prerelease' { + $result = Resolve-WorkflowEventRouting -EventName pull_request ` + -EventAction labeled ` + -IsTargetDefaultBranch $true ` + -HasImportantChanges $true ` + -HasPrereleaseLabel $true + + $result.ReleaseType | Should -Be 'Prerelease' + $result.ShouldRunBuildTest | Should -BeTrue + } + + It 'routes a closed PR to cleanup only' { + $result = Resolve-WorkflowEventRouting -EventName pull_request ` + -EventAction closed ` + -PullRequestIsMerged $true ` + -IsTargetDefaultBranch $true ` + -HasImportantChanges $true + + $result.ReleaseType | Should -Be 'None' + $result.ShouldRunBuildTest | Should -BeFalse + $result.ShouldCleanupEvent | Should -BeTrue + } +} + +Describe 'Select-PullRequestForPush' { + It 'selects the merged PR whose merge commit matches the pushed commit' { + $pullRequests = @( + [pscustomobject]@{ + Number = 411 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = $null + merge_commit_sha = 'open-pr-sha' + }, + [pscustomobject]@{ + Number = 390 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-08-15T00:00:00Z' + merge_commit_sha = 'pushed-sha' + } + ) + + $result = Select-PullRequestForPush -PullRequest $pullRequests -DefaultBranch main -CommitSha pushed-sha + + $result.Number | Should -Be 390 + } + + It 'rejects an open or mismatched PR association' { + $pullRequests = @( + [pscustomobject]@{ + Number = 411 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = $null + merge_commit_sha = 'pushed-sha' + }, + [pscustomobject]@{ + Number = 390 + Base = [pscustomobject]@{ Ref = 'main' } + merged_at = '2026-08-15T00:00:00Z' + merge_commit_sha = 'different-sha' + } + ) + + $result = Select-PullRequestForPush -PullRequest $pullRequests -DefaultBranch main -CommitSha pushed-sha + + $result | Should -BeNullOrEmpty + } +} + +Describe 'Get-FilesFromGitTree' { + It 'returns only files from a complete tree response' { + $tree = [pscustomobject]@{ + truncated = $false + tree = @( + [pscustomobject]@{ type = 'blob'; path = 'src/Module.psm1' } + [pscustomobject]@{ type = 'tree'; path = 'src' } + [pscustomobject]@{ type = 'blob'; path = 'README.md' } + ) + } + + $result = Get-FilesFromGitTree -Tree $tree + + $result | Should -Be @('src/Module.psm1', 'README.md') + } + + It 'rejects a truncated tree response' { + $tree = [pscustomobject]@{ + truncated = $true + tree = @() + } + + { Get-FilesFromGitTree -Tree $tree } | Should -Throw '*tree response was truncated*' + } +} diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index cd48d4dc..a36a9383 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -123,26 +123,57 @@ function Get-PublishConfiguration { function Get-GitHubPullRequest { <# .SYNOPSIS - Reads and validates the GitHub pull request from the event payload. + Reads normalized pull-request context from settings, with event-payload fallback. .DESCRIPTION - Loads the GitHub event from the input override or from the event path file. On a - pull_request event it returns the pull request head ref and labels. On any other - event (for example workflow_dispatch or schedule) there is no pull request, so it - returns $null and the caller resolves the current version without a version bump. + 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 with HeadRef and Labels properties for a pull_request event, or - $null when the event has no pull request (non-PR events). + PSCustomObject with pull-request metadata, or a default-branch direct-release + context with no pull-request number. .EXAMPLE - $pullRequest = Get-GitHubPullRequest + $pullRequest = Get-GitHubPullRequest -SettingsJson $actionInput.SettingsJson #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'SettingsJson', + Justification = 'Parameter is used inside a LogGroup script block.')] [CmdletBinding()] [OutputType([PSCustomObject])] - param() + param( + # The complete settings object, including normalized workflow context. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $SettingsJson + ) LogGroup 'Event information' { + $settings = $SettingsJson | ConvertFrom-Json + $context = $settings.Context + if ($context) { + $contextPullRequest = $context.PullRequest + if ($contextPullRequest) { + Write-Host "Using normalized pull request context for #$($contextPullRequest.Number)." + return [PSCustomObject]@{ + Number = $contextPullRequest.Number + HeadRef = $contextPullRequest.HeadRef + Labels = @($contextPullRequest.Labels) + } + } + + if ($context.IsPushToDefaultBranch -or $context.IsManualDispatchToDefaultBranch) { + Write-Host 'Using direct default-branch release context with the default patch bump.' + return [PSCustomObject]@{ + Number = $null + HeadRef = $context.DefaultBranch + Labels = @() + IsDirectRelease = $true + } + } + } + $eventJsonInput = $env:PSMODULE_RESOLVE_PSMODULEVERSION_INPUT_EventJson $githubEvent = if (-not [string]::IsNullOrWhiteSpace($eventJsonInput)) { $eventJsonInput | ConvertFrom-Json @@ -152,8 +183,7 @@ function Get-GitHubPullRequest { $pr = $githubEvent.pull_request if (-not $pr) { - Write-Host 'GitHub event does not contain pull_request data (non-PR event, e.g. workflow_dispatch or schedule).' - Write-Host 'No pull request context is available; the caller keeps the current version without a bump.' + Write-Host 'GitHub event does not contain pull_request data and no release context was normalized.' return $null } @@ -220,6 +250,20 @@ function Resolve-ReleaseDecision { $createRelease = $releaseType -eq 'Release' $createPrerelease = $releaseType -eq 'Prerelease' $shouldPublish = $createRelease -or $createPrerelease + $isCleanupOnly = $releaseType -eq 'None' + + if ($isCleanupOnly) { + return [PSCustomObject]@{ + ShouldPublish = $false + CreateRelease = $false + CreatePrerelease = $false + MajorRelease = $false + MinorRelease = $false + PatchRelease = $false + HasVersionBump = $false + PrereleaseName = $prereleaseName + } + } $ignoreRelease = ($labels | Where-Object { $Configuration.IgnoreLabels -contains $_ }).Count -gt 0 if ($ignoreRelease -and $shouldPublish) { @@ -227,27 +271,41 @@ function Resolve-ReleaseDecision { $shouldPublish = $false } - # Always evaluate the version-bump labels so the resolved version reflects what WOULD be - # created, regardless of whether this run publishes. ReleaseType (and the prerelease label - # that drives it) only controls whether and how we publish - never the version increment. - $majorRelease = ($labels | Where-Object { $Configuration.MajorLabels -contains $_ }).Count -gt 0 - $minorRelease = ($labels | Where-Object { $Configuration.MinorLabels -contains $_ }).Count -gt 0 -and -not $majorRelease - $patchRelease = ( - (($labels | Where-Object { $Configuration.PatchLabels -contains $_ }).Count -gt 0) -or $Configuration.AutoPatching - ) -and -not $majorRelease -and -not $minorRelease + $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 + if (-not $hasVersionBump) { - # No explicit bump label and no AutoPatching: still resolve a patch version so the run - # can preview what it would create, but do not publish a full release for an unlabeled change. Write-Host 'No version bump label and AutoPatching disabled; previewing a patch version without publishing.' $patchRelease = $true $hasVersionBump = $true $shouldPublish = $false } - # Anything that is not a published full release is surfaced as a prerelease - either a - # published prerelease (ShouldPublish) or a non-published preview. + if ($ignoreRelease) { + $createRelease = $false + $createPrerelease = $false + $shouldPublish = $false + } + if (-not $shouldPublish) { $createPrerelease = $true } diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index 9d143fba..0a0d023f 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -8,7 +8,7 @@ Import-Module -Name "$PSScriptRoot/Resolve-PSModuleVersion.Helpers.psm1" -Force $actionInput = Read-ActionInput $config = Get-PublishConfiguration -SettingsJson $actionInput.SettingsJson -$pullRequest = Get-GitHubPullRequest +$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 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 5c212c9a..72e66aad 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -451,4 +451,83 @@ Describe 'Resolve-PSModuleVersion' { } } } + + Describe 'Get-GitHubPullRequest' { + It 'uses the normalized pull request from a default-branch push' { + $settings = @{ + Context = @{ + IsPushToDefaultBranch = $true + DefaultBranch = 'main' + PullRequest = @{ + Number = 390 + HeadRef = 'feature/push-release' + Labels = @('minor') + } + } + } | ConvertTo-Json -Depth 5 + + $result = Get-GitHubPullRequest -SettingsJson $settings + + $result.Number | Should -Be 390 + $result.HeadRef | Should -Be 'feature/push-release' + $result.Labels | Should -Be @('minor') + } + + It 'creates default patch context for a direct default-branch push' { + $settings = @{ + Context = @{ + IsPushToDefaultBranch = $true + DefaultBranch = 'main' + PullRequest = $null + } + } | ConvertTo-Json -Depth 5 + + $result = Get-GitHubPullRequest -SettingsJson $settings + + $result.Number | Should -BeNullOrEmpty + $result.HeadRef | Should -Be 'main' + $result.Labels | Should -BeNullOrEmpty + $result.IsDirectRelease | Should -BeTrue + } + } + + 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 }) + + $result.ShouldPublish | Should -BeTrue + $result.PatchRelease | 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 = @() }) + + $result.ShouldPublish | Should -BeFalse + $result.PatchRelease | Should -BeTrue + } + + 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.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 '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 087bf89489ff8185de732aba8650290a204c2460 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 08:49:23 +0200 Subject: [PATCH 02/14] Publish stable releases from main pushes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Cleanup-PSModulePrereleases/action.yml | 5 ++ .../src/cleanup.ps1 | 18 ++++-- .github/actions/Publish-PSModule/action.yml | 5 ++ .../actions/Publish-PSModule/src/publish.ps1 | 40 +++++++----- .github/actions/Release-PSModule/action.yml | 10 +++ .../actions/Release-PSModule/src/release.ps1 | 61 +++++++++++++------ .../tests/Release-PSModule.WhatIf.Tests.ps1 | 18 ++++++ .github/workflows/Publish-Module.yml | 13 +++- .github/workflows/Release.yml | 14 ++++- .github/workflows/Workflow-Test-Default.yml | 11 +++- .../workflows/Workflow-Test-WithManifest.yml | 11 +++- .github/workflows/workflow.yml | 4 ++ 12 files changed, 166 insertions(+), 44 deletions(-) diff --git a/.github/actions/Cleanup-PSModulePrereleases/action.yml b/.github/actions/Cleanup-PSModulePrereleases/action.yml index eaf64ad7..e9ebcecb 100644 --- a/.github/actions/Cleanup-PSModulePrereleases/action.yml +++ b/.github/actions/Cleanup-PSModulePrereleases/action.yml @@ -11,6 +11,10 @@ inputs: description: GitHub release tag to retain during cleanup. required: false default: '' + PullRequest: + description: Normalized pull request context JSON from Get-PSModuleSettings. + required: false + default: '' WhatIf: description: If specified, the action will only log the changes it would make. required: false @@ -33,5 +37,6 @@ runs: env: GH_TOKEN: ${{ env.GH_TOKEN }} PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf: ${{ inputs.WhatIf }} + PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest: ${{ inputs.PullRequest }} PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ inputs.ReleaseTag }} run: ${{ github.action_path }}/src/cleanup.ps1 diff --git a/.github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1 b/.github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1 index a10bdf1d..d61da5b2 100644 --- a/.github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1 +++ b/.github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1 @@ -9,13 +9,19 @@ Import-Module -Name 'PSModule' -Force LogGroup 'Load inputs' { $whatIf = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf -eq 'true' - $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH - $githubEvent = $githubEventJson | ConvertFrom-Json - $pull_request = $githubEvent.pull_request - if (-not $pull_request) { - throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.' + $pullRequestJson = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest + $prHeadRef = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { + ($pullRequestJson | ConvertFrom-Json).HeadRef + } else { + $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH + $githubEvent = $githubEventJson | ConvertFrom-Json + $githubEvent.pull_request.head.ref + } + + if ([string]::IsNullOrWhiteSpace($prHeadRef)) { + Write-Host '::notice::No pull request head ref is available. Nothing to cleanup.' + exit 0 } - $prHeadRef = $pull_request.head.ref $prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]' if ([string]::IsNullOrWhiteSpace($prereleaseName)) { diff --git a/.github/actions/Publish-PSModule/action.yml b/.github/actions/Publish-PSModule/action.yml index 038ddaea..b56277af 100644 --- a/.github/actions/Publish-PSModule/action.yml +++ b/.github/actions/Publish-PSModule/action.yml @@ -13,6 +13,10 @@ inputs: PSGALLERY_API_KEY: description: PowerShell Gallery API Key. required: true + PullRequest: + description: Normalized pull request context JSON from Get-PSModuleSettings. + required: false + default: '' WhatIf: description: If specified, the action will only log the changes it would make, but will not publish the module. required: false @@ -50,5 +54,6 @@ runs: PSMODULE_PUBLISH_PSMODULE_INPUT_Name: ${{ inputs.Name }} PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath: ${{ inputs.ModulePath }} PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY: ${{ inputs.PSGALLERY_API_KEY }} + PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest: ${{ inputs.PullRequest }} PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf: ${{ inputs.WhatIf }} run: ${{ github.action_path }}/src/publish.ps1 diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index dc512c0a..49a6fe3b 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -2,10 +2,6 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'psGalleryApiKey', Justification = 'Variable is used in script blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'prNumber', - Justification = 'Variable is used in script blocks.' -)] [CmdletBinding()] param() @@ -47,17 +43,29 @@ LogGroup 'Load inputs' { } #endregion Load inputs -#region Load PR information -LogGroup 'Load PR information' { - $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH - $githubEvent = $githubEventJson | ConvertFrom-Json - $pull_request = $githubEvent.pull_request - if (-not $pull_request) { - throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.' +#region Load release context +LogGroup 'Load release context' { + $pullRequestJson = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest + $pullRequest = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { + $pullRequestJson | ConvertFrom-Json + } else { + $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH + $githubEvent = $githubEventJson | ConvertFrom-Json + if ($githubEvent.pull_request) { + [pscustomobject]@{ + Number = $githubEvent.pull_request.number + } + } + } + + $prNumber = $pullRequest.Number + if ($prNumber) { + Write-Host "Pull request: [#$prNumber]" + } else { + Write-Host 'No pull request context is available; publication comments are disabled.' } - $prNumber = $pull_request.number } -#endregion Load PR information +#endregion Load release context #region Resolve version from manifest # The manifest was stamped with the final version during Build-PSModule. This step is read-only @@ -145,18 +153,20 @@ LogGroup 'Publish to PSGallery' { } } - if ($whatIf) { + if ($whatIf -and $prNumber) { Write-Host ( "gh pr comment $prNumber -b " + "'✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)'" ) - } else { + } elseif (-not $whatIf -and $prNumber) { Write-Host "::notice title=✅ $releaseType`: PowerShell Gallery - $name $publishPSVersion::$psGalleryReleaseLink" gh pr comment $prNumber -b "✅ $releaseType`: PowerShell Gallery - [$name $publishPSVersion]($psGalleryReleaseLink)" if ($LASTEXITCODE -ne 0) { Write-Error 'Failed to comment on the pull request.' exit $LASTEXITCODE } + } else { + Write-Host "::notice title=✅ $releaseType`: PowerShell Gallery - $name $publishPSVersion::$psGalleryReleaseLink" } } #endregion Publish to PSGallery diff --git a/.github/actions/Release-PSModule/action.yml b/.github/actions/Release-PSModule/action.yml index d83f18da..377a92d9 100644 --- a/.github/actions/Release-PSModule/action.yml +++ b/.github/actions/Release-PSModule/action.yml @@ -37,6 +37,14 @@ inputs: ReleaseTag: description: Full GitHub release tag resolved by Resolve-PSModuleVersion. required: true + PullRequest: + description: Normalized pull request context JSON from Get-PSModuleSettings. + required: false + default: '' + CommitSha: + description: Commit SHA that produced the module artifact. Stable tags target this exact commit. + required: false + default: '' outputs: ReleaseTag: @@ -70,4 +78,6 @@ runs: PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag: ${{ inputs.ReleaseTag }} + PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest: ${{ inputs.PullRequest }} + PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha: ${{ inputs.CommitSha || github.sha }} run: ${{ github.action_path }}/src/release.ps1 diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 652d5d78..bc53dcd3 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -33,11 +33,15 @@ Justification = 'Variable is used in script blocks.' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'prNumber', + 'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef', Justification = 'Variable is used in script blocks.' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef', + 'PSUseDeclaredVarsMoreThanAssignments', 'commitSha', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'commitMessage', Justification = 'Variable is used in script blocks.' )] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( @@ -80,6 +84,7 @@ LogGroup 'Load inputs' { $usePRTitleAsReleaseName = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true' $usePRTitleAsNotesHeading = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true' $releaseTag = $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag + $commitSha = $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha if ([string]::IsNullOrWhiteSpace($releaseTag)) { throw 'ReleaseTag is required. Ensure Publish.Module.Resolution.FullVersion is passed from the Plan job.' } @@ -90,15 +95,29 @@ LogGroup 'Load inputs' { Write-Host "WhatIf: [$whatIf]" } -LogGroup 'Load PR information' { +LogGroup 'Load release context' { $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH $githubEvent = $githubEventJson | ConvertFrom-Json - $pull_request = $githubEvent.pull_request - if (-not $pull_request) { - throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.' + $pullRequestJson = $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest + $pullRequest = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') { + $pullRequestJson | ConvertFrom-Json + } elseif ($githubEvent.pull_request) { + [pscustomobject]@{ + Number = $githubEvent.pull_request.number + Title = $githubEvent.pull_request.title + Body = $githubEvent.pull_request.body + HeadRef = $githubEvent.pull_request.head.ref + } + } + $prNumber = $pullRequest.Number + $prHeadRef = $pullRequest.HeadRef + $commitMessage = $githubEvent.head_commit.message + + if ($prNumber) { + Write-Host "Pull request: [#$prNumber]" + } else { + Write-Host 'No pull request context is available; using the pushed commit message for release notes.' } - $prNumber = $pull_request.number - $prHeadRef = $pull_request.head.ref } LogGroup 'Resolve version from manifest' { @@ -153,6 +172,7 @@ LogGroup 'Resolve version from manifest' { ReleaseTag = $releaseTag PRNumber = $prNumber PRHeadRef = $prHeadRef + CommitSha = $commitSha } | Format-List | Out-String ) } @@ -178,31 +198,38 @@ LogGroup 'Create GitHub release' { } if (-not $releaseExists) { - if ($usePRTitleAsReleaseName -and $pull_request.title) { - $releaseCreateCommand += @('--title', $pull_request.title) - Write-Host "Using PR title as release name: [$($pull_request.title)]" + if ($usePRTitleAsReleaseName -and $pullRequest.Title) { + $releaseCreateCommand += @('--title', $pullRequest.Title) + Write-Host "Using PR title as release name: [$($pullRequest.Title)]" } else { $releaseCreateCommand += @('--title', $releaseTag) } # Build release notes content. Uses a file to preserve special characters. - if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) { - $notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)" + if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pullRequest.Title -and $pullRequest.Body) { + $notes = "# $($pullRequest.Title) (#$prNumber)`n`n$($pullRequest.Body)" $notesFilePath = [System.IO.Path]::GetTempFileName() Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 $releaseCreateCommand += @('--notes-file', $notesFilePath) Write-Host 'Using PR title as H1 heading with link and body as release notes' - } elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) { + } elseif ($usePRBodyAsReleaseNotes -and $pullRequest.Body) { $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8 + Set-Content -Path $notesFilePath -Value $pullRequest.Body -Encoding utf8 $releaseCreateCommand += @('--notes-file', $notesFilePath) Write-Host 'Using PR body as release notes' + } elseif (-not [string]::IsNullOrWhiteSpace($commitMessage)) { + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $commitMessage -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using the pushed commit message as release notes' } else { $releaseCreateCommand += @('--generate-notes') } if ($createPrerelease) { $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') + } elseif (-not [string]::IsNullOrWhiteSpace($commitSha)) { + $releaseCreateCommand += @('--target', $commitSha) } try { @@ -247,9 +274,9 @@ LogGroup 'Create GitHub release' { } } - if ($whatIf) { + if ($whatIf -and $prNumber) { Write-Host "gh pr comment $prNumber -b '✅ $($releaseType): GitHub - $name $releaseTag'" - } else { + } elseif (-not $whatIf -and $prNumber) { gh pr comment $prNumber -b "✅ $releaseType`: GitHub - [$name $releaseTag]($releaseURL)" if ($LASTEXITCODE -ne 0) { throw 'Failed to comment on the pull request.' diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index 3c928b0f..872fc691 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -22,6 +22,8 @@ BeforeAll { 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName' 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading' 'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag' + 'PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest' + 'PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha' ) $script:originalEnvironment = @{} foreach ($name in $script:environmentVariableNames) { @@ -88,6 +90,8 @@ Describe 'Release-PSModule WhatIf' { $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'true' $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = '' } It 'creates a prefixed prerelease tag without GitHub side effects' { @@ -122,4 +126,18 @@ Describe 'Release-PSModule WhatIf' { $ghCalls | Should -Not -Match 'release create' Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1' } + + It 'creates a stable release from a direct push without pull request context' { + $manifest = Get-Content -Path $manifestPath -Raw + $manifest -replace "Prerelease = 'preview.1'", "Prerelease = ''" | Set-Content -Path $manifestPath + @{ head_commit = @{ message = 'Publish direct push release' } } | + ConvertTo-Json -Depth 5 | Set-Content -Path $eventPath + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = 'pushed-commit-sha' + + $releaseOutput = & $script:releaseScriptPath 6>&1 | Out-String + + Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3' + $releaseOutput | Should -Match '--target pushed-commit-sha' + } } diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 149bb78a..a465df6f 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -54,6 +54,7 @@ jobs: permission-pull-requests: write - name: Publish module + id: publish-module if: fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' uses: ./_wf/.github/actions/Publish-PSModule env: @@ -62,12 +63,16 @@ jobs: Name: ${{ fromJson(inputs.Settings).Name }} ModulePath: outputs/module PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Create GitHub release id: create-github-release - if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' + if: >- + !cancelled() && + fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' && + steps.publish-module.outcome == 'success' uses: ./_wf/.github/actions/Release-PSModule env: GH_TOKEN: ${{ steps.App-Token.outputs.token }} @@ -80,6 +85,8 @@ jobs: UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }} UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }} ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} + PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} + CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Cleanup prereleases @@ -87,7 +94,8 @@ jobs: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' && (fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType == 'None' || - steps.create-github-release.outcome == 'success') + (steps.publish-module.outcome == 'success' && + steps.create-github-release.outcome == 'success')) uses: ./_wf/.github/actions/Cleanup-PSModulePrereleases env: GH_TOKEN: ${{ steps.App-Token.outputs.token }} @@ -95,4 +103,5 @@ jobs: WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} AutoCleanup: ${{ fromJson(inputs.Settings).Publish.Module.AutoCleanup }} ReleaseTag: ${{ steps.create-github-release.outputs.ReleaseTag }} + PullRequest: ${{ toJson(fromJson(inputs.Settings).Context.PullRequest) }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 40814e0a..28009323 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -1,8 +1,17 @@ name: Release -run-name: "Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" +run-name: "Release - ${{ github.event_name }} [${{ github.event.pull_request.title || github.event.head_commit.message || github.ref_name }}] by @${{ github.actor }}" on: + push: + branches: + - main + paths: + - '.github/actions/**' + - '.github/workflows/**' + - '!.github/workflows/Release.yml' + - '!.github/workflows/Linter.yml' + - '!.github/workflows/Workflow-Test-*' pull_request: branches: - main @@ -21,7 +30,7 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: write # Required to create releases @@ -29,6 +38,7 @@ permissions: jobs: Release: + if: github.event_name != 'pull_request' || github.event.action != 'closed' || github.event.pull_request.merged == false runs-on: ubuntu-latest steps: - name: Checkout repo diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 03ed7cfa..30021d10 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -1,9 +1,18 @@ name: Workflow-Test [Default] -run-name: 'Workflow-Test [Default] - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}' +run-name: 'Workflow-Test [Default] - ${{ github.event_name }} [${{ github.event.pull_request.title || github.event.head_commit.message || github.ref_name }}] by @${{ github.actor }}' on: workflow_dispatch: + push: + branches: + - main + paths: + - '.github/actions/**' + - '.github/workflows/**' + - 'tests/srcTestRepo/**' + - '!.github/workflows/Release.yml' + - '!.github/workflows/Linter.yml' pull_request: paths: - '.github/actions/**' diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 0340f329..00935376 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -1,9 +1,18 @@ name: Workflow-Test [WithManifest] -run-name: 'Workflow-Test [WithManifest] - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}' +run-name: 'Workflow-Test [WithManifest] - ${{ github.event_name }} [${{ github.event.pull_request.title || github.event.head_commit.message || github.ref_name }}] by @${{ github.actor }}' on: workflow_dispatch: + push: + branches: + - main + paths: + - '.github/actions/**' + - '.github/workflows/**' + - 'tests/srcWithManifestTestRepo/**' + - '!.github/workflows/Release.yml' + - '!.github/workflows/Linter.yml' pull_request: paths: - '.github/actions/**' diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 32de93a0..ddb67872 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -75,6 +75,10 @@ permissions: pages: write # to deploy to Pages id-token: write # to verify the deployment originates from an appropriate source +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: # Runs on: # - ✅ Open/Updated PR - Always runs to load configuration From 5086c80567891417e1554c90164fad2544c6c601 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 08:49:28 +0200 Subject: [PATCH 03/14] Document push-driven stable releases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/content/get-started/repository-setup.md | 12 +++++++++--- docs/content/guides/calling-the-workflow.md | 16 ++++++++++++---- .../content/guides/versioning-and-releases.md | 19 +++++++++++++------ docs/content/specification/design.md | 11 +++++++---- docs/content/specification/spec.md | 4 ++-- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 821dada5..83cb5238 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -38,6 +38,9 @@ on: workflow_dispatch: schedule: - cron: '0 0 * * *' + push: + branches: + - main pull_request: branches: - main @@ -47,10 +50,11 @@ on: - reopened - synchronize - labeled + - unlabeled concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: write @@ -68,8 +72,10 @@ jobs: GitHubAppPrivateKey: ${{ secrets.SHELLY_PRIVATE_KEY }} ``` -Every permission in that block is required. See [Workflow inputs](../reference/workflow-inputs.md) for what each one is -used for, and [Calling the workflow](../guides/calling-the-workflow.md) for passing test secrets and variables. +Every permission in that block is required. A push to `main` publishes a stable release after the full pipeline passes; +the pull-request trigger handles CI, prereleases, and prerelease cleanup. See +[Workflow inputs](../reference/workflow-inputs.md) for what each permission is used for, and +[Calling the workflow](../guides/calling-the-workflow.md) for passing test secrets and variables. ## 4. Add the settings file diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 799da79e..567f968a 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -21,6 +21,9 @@ on: workflow_dispatch: schedule: - cron: '0 0 * * *' + push: + branches: + - main pull_request: branches: - main @@ -30,10 +33,11 @@ on: - reopened - synchronize - labeled + - unlabeled concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false permissions: contents: write @@ -53,6 +57,10 @@ jobs: +Stable releases are evaluated from a push to the default branch. A merged pull request supplies its version label and +release notes; a direct default-branch push or a manual dispatch uses the default `Patch` bump and commit-based notes. +Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. + ## Passing test data The reusable workflow at `.github/workflows/workflow.yml` declares four workflow-call secrets, @@ -175,9 +183,9 @@ Notes: ## Important file change detection -The workflow automatically detects whether a pull request contains changes to "important" files that should enter the -build, test, and publish path. This prevents unnecessary work and releases when only files outside the configured -patterns are modified. +The workflow automatically detects whether a pull request or default-branch push contains changes to "important" files +that should enter the build, test, and publish path. This prevents unnecessary work and releases when only files outside +the configured patterns are modified. ### Files that trigger the important-change path diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 682bd3d5..3e76124f 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -1,12 +1,13 @@ --- title: Versioning and releases -description: How Process-PSModule resolves a version from pull-request labels, what a release produces, and how prereleases are published and cleaned up. +description: How Process-PSModule resolves a version from pull-request labels, publishes stable releases from default-branch pushes, and cleans up prereleases. --- # 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. +requests and resolved once, in the Plan stage, before anything is built. Stable publication occurs only from a push to +the configured default branch. ## Flow @@ -18,6 +19,10 @@ requests and resolved once, in the Plan stage, before anything is built. Test and lint stages run before the publish gates, and publish is blocked when required checks fail. +An open labelled pull request can publish a prerelease. A closed pull request only cleans up its prereleases. When a +pull request merges, the resulting push to the default branch resolves that pull request's labels and creates the +stable release from the exact pushed commit. + ## Version labels The bump comes from the pull-request label; the next version is computed as `current version + bump`. @@ -26,18 +31,20 @@ The bump comes from the pull-request label; the next version is computed as `cur | --- | --- | | `major` / `breaking` | Breaking change; bump `MAJOR`. | | `minor` / `feature` | New feature; bump `MINOR`. | -| `patch` / `fix` | Bugfix; bump `PATCH`. Applied by default when no label is present. | +| `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. +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). ## Branch types -- **Main (stable)** — publishes stable releases. A prerelease label publishes a prerelease from `main`. +- **Main (stable)** — pushes publish stable releases. A prerelease label on an open pull request publishes a prerelease. - **Development** — optional prerelease branch (for example `dev`). Each push publishes a prerelease. - **Feature branch** — optional feature branch. A prerelease label publishes a prerelease for testing. @@ -49,8 +56,8 @@ A pull request labelled `Prerelease` publishes a prerelease version (for example 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. -When a pull request is closed without merging, the prerelease versions and tags created for it are removed, so -abandoned work leaves no orphaned prereleases. This is controlled by `Publish.Module.AutoCleanup`. +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`. ## What a release produces diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 9990831b..27d903b3 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -11,8 +11,9 @@ The behaviour in the [spec](spec.md) is delivered by a **single reusable GitHub ### Single entry point -The reusable workflow accepts a caller workflow and minimal caller configuration: a `pull_request`-triggered job that -calls `workflow.yml` and passes the `PSGALLERY_API_KEY` secret. The full caller template is in +The reusable workflow accepts a caller workflow and minimal caller configuration: a `pull_request`-triggered job for +CI and prereleases plus a default-branch `push` trigger for stable publication. The caller calls `workflow.yml` and +passes the required secrets. The full caller template is in [Repository setup](../get-started/repository-setup.md#3-add-the-caller-workflow), and the interface it targets is documented in [Workflow inputs](../reference/workflow-inputs.md). @@ -46,8 +47,10 @@ That enriched object is an internal inter-workflow contract, not an authoring fo ## Scenario matrix -Release intent comes from pull-request labels and is resolved once, in the Plan job. The label-to-bump mapping, the -handling of conflicting labels, and the branch types that may publish are documented in +Release intent comes from pull-request labels and is resolved once, in the Plan job. A default-branch push resolves the +merged pull request for its labels, while a direct push or manual dispatch defaults to `Patch` regardless of +`AutoPatching`. 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). Tests run on **Windows** (latest), **Linux** (Ubuntu latest), and **macOS** (latest). Failures on any platform block diff --git a/docs/content/specification/spec.md b/docs/content/specification/spec.md index 90db5d81..292f2e3a 100644 --- a/docs/content/specification/spec.md +++ b/docs/content/specification/spec.md @@ -7,7 +7,7 @@ description: Requirements for Process-PSModule — an end-to-end PowerShell modu ## Premise -A PowerShell module's lifecycle — from source code to versioned, published artifact — MUST be reliable, repeatable, and as automated as possible. Contributors focus on code and tests; the pipeline focuses on build, test, quality, documentation, and release. The pipeline MUST be driven entirely by pull-request labels and merge events, never by manual intervention or external tooling. The result is a versioned, immutable artifact — a module package in the PowerShell Gallery and its documentation site — paired with a GitHub Release and a git tag. +A PowerShell module's lifecycle — from source code to versioned, published artifact — MUST be reliable, repeatable, and as automated as possible. Contributors focus on code and tests; the pipeline focuses on build, test, quality, documentation, and release. The pipeline MUST use GitHub pull-request labels and default-branch pushes for release decisions, with direct pushes and workflow dispatch as supported GitHub-native release paths. The result is a versioned, immutable artifact — a module package in the PowerShell Gallery and its documentation site — paired with a GitHub Release and a git tag. ### Principles @@ -43,7 +43,7 @@ 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. It MUST compute the next version automatically, never reading or writing a hand-edited version file. A merge 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 read pull-request labels (`Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`) to decide the semantic-version bump. It MUST compute the next version automatically, never reading or writing a hand-edited version file. A 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. ### FR6 — Produce immutable, linkable releases { #fr6 } From 2da77749e9cc2cb6b77414fc11e8950230953da2 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:05:05 +0200 Subject: [PATCH 04/14] Prevent dogfood Pages deployments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Workflow-Test-Default.yml | 3 +-- .github/workflows/Workflow-Test-WithManifest.yml | 3 +-- tests/srcTestRepo/.github/PSModule.yml | 3 +++ tests/srcWithManifestTestRepo/.github/PSModule.yml | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 30021d10..85edb8d6 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -54,8 +54,7 @@ jobs: with: WorkingDirectory: tests/srcTestRepo ImportantFilePatterns: | - ^src/ - ^README\.md$ + ^tests/srcTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 00935376..54547333 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -54,8 +54,7 @@ jobs: with: WorkingDirectory: tests/srcWithManifestTestRepo ImportantFilePatterns: | - ^src/ - ^README\.md$ + ^tests/srcWithManifestTestRepo/ ^\.github/actions/ ^\.github/workflows/(?!Release\.yml$|Linter\.yml$) diff --git a/tests/srcTestRepo/.github/PSModule.yml b/tests/srcTestRepo/.github/PSModule.yml index 92d30f1e..fca24d6c 100644 --- a/tests/srcTestRepo/.github/PSModule.yml +++ b/tests/srcTestRepo/.github/PSModule.yml @@ -1,3 +1,6 @@ Name: PSModuleTest2 +Build: + Site: + Skip: true Linter: Skip: true diff --git a/tests/srcWithManifestTestRepo/.github/PSModule.yml b/tests/srcWithManifestTestRepo/.github/PSModule.yml index 4ecb9116..83502ef3 100644 --- a/tests/srcWithManifestTestRepo/.github/PSModule.yml +++ b/tests/srcWithManifestTestRepo/.github/PSModule.yml @@ -1,4 +1,7 @@ Name: PSModuleTest +Build: + Site: + Skip: true Test: SourceCode: Skip: true From 6b08d730646d0e58812354a5c33fe92ce2e96dd3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:09:59 +0200 Subject: [PATCH 05/14] Allow site builds without publication Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Get-PSModuleSettings/src/Settings.schema.json | 10 ++++++++++ .github/actions/Get-PSModuleSettings/src/main.ps1 | 11 +++++++---- docs/content/reference/settings.md | 3 +++ tests/srcTestRepo/.github/PSModule.yml | 2 +- tests/srcWithManifestTestRepo/.github/PSModule.yml | 7 +++---- 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json index fdbc9013..a51b90ac 100644 --- a/.github/actions/Get-PSModuleSettings/src/Settings.schema.json +++ b/.github/actions/Get-PSModuleSettings/src/Settings.schema.json @@ -196,6 +196,16 @@ "description": "The type of release to create: Release (stable), Prerelease, or None." } } + }, + "Site": { + "type": "object", + "description": "Documentation site publish configuration", + "properties": { + "Skip": { + "type": "boolean", + "description": "Skip publishing the generated documentation site" + } + } } } }, diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 5907b884..475ee8de 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -203,6 +203,9 @@ $settings = [pscustomobject]@{ UsePRBodyAsReleaseNotes = $settings.Publish.Module.UsePRBodyAsReleaseNotes ?? $true UsePRTitleAsNotesHeading = $settings.Publish.Module.UsePRTitleAsNotesHeading ?? $true } + Site = [pscustomobject]@{ + Skip = $settings.Publish.Site.Skip ?? $false + } } Linter = [pscustomobject]@{ Skip = $settings.Linter.Skip ?? $false @@ -688,10 +691,10 @@ LogGroup 'Calculate Job Run Conditions:' { $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Desired -Value (($releaseType -ne 'None') -or $shouldAutoCleanup) -Force $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name Enabled -Value (($releaseType -ne 'None') -or $shouldAutoCleanup) -Force - $settings.Publish | Add-Member -MemberType NoteProperty -Name Site -Value ([pscustomobject]@{ - Desired = $releaseType -eq 'Release' - Enabled = $releaseType -eq 'Release' - }) -Force + $settings.Publish.Site | Add-Member -MemberType NoteProperty -Name Desired -Value ($releaseType -eq 'Release') -Force + $settings.Publish.Site | Add-Member -MemberType NoteProperty -Name Enabled -Value ( + $releaseType -eq 'Release' -and -not $settings.Publish.Site.Skip + ) -Force $settings | Add-Member -MemberType NoteProperty -Name HasImportantChanges -Value $hasImportantChanges diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index dbbbe75b..2f39ae23 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -58,6 +58,7 @@ For worked examples, see [Configuring the pipeline](../guides/configuring-the-pi | `Build.Docs.Skip` | `Boolean` | Skip documentation build | `false` | | `Build.Docs.ShowSummaryOnSuccess` | `Boolean` | Show super-linter summary on success for documentation linting | `false` | | `Build.Site.Skip` | `Boolean` | Skip site build | `false` | +| `Publish.Site.Skip` | `Boolean` | Skip publishing the generated documentation site | `false` | | `Publish.Module.Skip` | `Boolean` | Skip module publishing | `false` | | `Publish.Module.AutoCleanup` | `Boolean` | Automatically clean up old prerelease tags when merging to main or when a PR is abandoned | `true` | | `Publish.Module.AutoPatching` | `Boolean` | Automatically patch module version | `true` | @@ -137,6 +138,8 @@ Test: StepSummaryMode: 'Missed, Files' Publish: + Site: + Skip: false Module: Skip: false AutoCleanup: true diff --git a/tests/srcTestRepo/.github/PSModule.yml b/tests/srcTestRepo/.github/PSModule.yml index fca24d6c..fcdce7c9 100644 --- a/tests/srcTestRepo/.github/PSModule.yml +++ b/tests/srcTestRepo/.github/PSModule.yml @@ -1,5 +1,5 @@ Name: PSModuleTest2 -Build: +Publish: Site: Skip: true Linter: diff --git a/tests/srcWithManifestTestRepo/.github/PSModule.yml b/tests/srcWithManifestTestRepo/.github/PSModule.yml index 83502ef3..12c41397 100644 --- a/tests/srcWithManifestTestRepo/.github/PSModule.yml +++ b/tests/srcWithManifestTestRepo/.github/PSModule.yml @@ -1,7 +1,9 @@ Name: PSModuleTest -Build: +Publish: Site: Skip: true + Module: + AutoCleanup: false Test: SourceCode: Skip: true @@ -12,9 +14,6 @@ Test: Skip: false CodeCoverage: PercentTarget: 1 -Publish: - Module: - AutoCleanup: false Linter: env: VALIDATE_BIOME_FORMAT: false From fa3fb68d924e3d152276168e1b0c9a4caf2857fd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:14:05 +0200 Subject: [PATCH 06/14] Format settings workflow helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 4b2775e3..c5825544 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -46,27 +46,27 @@ function Resolve-WorkflowEventRouting { ) [pscustomobject]@{ - IsPR = $isPR - IsPush = $isPush - IsManualDispatch = $isManualDispatch - IsOpenOrUpdatedPR = $isOpenOrUpdatedPR - IsOpenOrLabeledPR = $isOpenOrLabeledPR - IsClosedPR = $isClosedPR - IsAbandonedPR = $isAbandonedPR - IsMergedPR = $isMergedPR - IsTargetDefaultBranch = $IsTargetDefaultBranch - IsPushToDefaultBranch = $IsPushToDefaultBranch + IsPR = $isPR + IsPush = $isPush + IsManualDispatch = $isManualDispatch + IsOpenOrUpdatedPR = $isOpenOrUpdatedPR + IsOpenOrLabeledPR = $isOpenOrLabeledPR + IsClosedPR = $isClosedPR + IsAbandonedPR = $isAbandonedPR + IsMergedPR = $isMergedPR + IsTargetDefaultBranch = $IsTargetDefaultBranch + IsPushToDefaultBranch = $IsPushToDefaultBranch IsManualDispatchToDefaultBranch = $IsManualDispatchToDefaultBranch - ShouldPrerelease = $shouldPrerelease - ReleaseType = if ($shouldRelease) { + ShouldPrerelease = $shouldPrerelease + ReleaseType = if ($shouldRelease) { 'Release' } elseif ($shouldPrerelease) { 'Prerelease' } else { 'None' } - ShouldRunBuildTest = (-not $isClosedPR) -and $HasImportantChanges - ShouldCleanupEvent = $isClosedPR + ShouldRunBuildTest = (-not $isClosedPR) -and $HasImportantChanges + ShouldCleanupEvent = $isClosedPR } } @@ -106,7 +106,7 @@ function Get-FilesFromGitTree { Returns the files contained in a complete Git tree response. #> [CmdletBinding()] - [OutputType([string[]])] + [OutputType([string])] param( [Parameter(Mandatory)] [PSCustomObject] $Tree @@ -116,7 +116,7 @@ function Get-FilesFromGitTree { throw 'Cannot determine changed files because the Git tree response was truncated.' } - @($Tree.tree | + $Tree.tree | Where-Object { $_.type -eq 'blob' } | - Select-Object -ExpandProperty path) + Select-Object -ExpandProperty path } From 5f2d4bf872f1c14cf3dde55951f0e20e293007dd Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:17:40 +0200 Subject: [PATCH 07/14] Order fixture settings by pipeline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/srcWithManifestTestRepo/.github/PSModule.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/srcWithManifestTestRepo/.github/PSModule.yml b/tests/srcWithManifestTestRepo/.github/PSModule.yml index 12c41397..9dcd2583 100644 --- a/tests/srcWithManifestTestRepo/.github/PSModule.yml +++ b/tests/srcWithManifestTestRepo/.github/PSModule.yml @@ -1,9 +1,4 @@ Name: PSModuleTest -Publish: - Site: - Skip: true - Module: - AutoCleanup: false Test: SourceCode: Skip: true @@ -14,6 +9,11 @@ Test: Skip: false CodeCoverage: PercentTarget: 1 +Publish: + Site: + Skip: true + Module: + AutoCleanup: false Linter: env: VALIDATE_BIOME_FORMAT: false From 56195c686d7345564645fabe79cce0f39e5ff0fb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:31:48 +0200 Subject: [PATCH 08/14] Harden release workflow routing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/Release.yml | 14 +--- .github/workflows/Workflow-Test-Default.yml | 11 +++- .../workflows/Workflow-Test-WithManifest.yml | 11 +++- .github/workflows/workflow.yml | 66 +++++++++---------- 4 files changed, 53 insertions(+), 49 deletions(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 28009323..2a354f0e 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -1,17 +1,8 @@ name: Release -run-name: "Release - ${{ github.event_name }} [${{ github.event.pull_request.title || github.event.head_commit.message || github.ref_name }}] by @${{ github.actor }}" +run-name: "Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" on: - push: - branches: - - main - paths: - - '.github/actions/**' - - '.github/workflows/**' - - '!.github/workflows/Release.yml' - - '!.github/workflows/Linter.yml' - - '!.github/workflows/Workflow-Test-*' pull_request: branches: - main @@ -29,7 +20,7 @@ on: - '!.github/workflows/Workflow-Test-*' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false permissions: @@ -38,7 +29,6 @@ permissions: jobs: Release: - if: github.event_name != 'pull_request' || github.event.action != 'closed' || github.event.pull_request.merged == false runs-on: ubuntu-latest steps: - name: Checkout repo diff --git a/.github/workflows/Workflow-Test-Default.yml b/.github/workflows/Workflow-Test-Default.yml index 85edb8d6..91e2c078 100644 --- a/.github/workflows/Workflow-Test-Default.yml +++ b/.github/workflows/Workflow-Test-Default.yml @@ -14,6 +14,13 @@ on: - '!.github/workflows/Release.yml' - '!.github/workflows/Linter.yml' pull_request: + types: + - closed + - opened + - reopened + - synchronize + - labeled + - unlabeled paths: - '.github/actions/**' - '.github/workflows/**' @@ -24,8 +31,8 @@ on: - cron: '0 0 * * *' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write diff --git a/.github/workflows/Workflow-Test-WithManifest.yml b/.github/workflows/Workflow-Test-WithManifest.yml index 54547333..74085bdb 100644 --- a/.github/workflows/Workflow-Test-WithManifest.yml +++ b/.github/workflows/Workflow-Test-WithManifest.yml @@ -14,6 +14,13 @@ on: - '!.github/workflows/Release.yml' - '!.github/workflows/Linter.yml' pull_request: + types: + - closed + - opened + - reopened + - synchronize + - labeled + - unlabeled paths: - '.github/actions/**' - '.github/workflows/**' @@ -24,8 +31,8 @@ on: - cron: '0 0 * * *' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index ddb67872..bd097a45 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -76,14 +76,14 @@ permissions: id-token: write # to verify the deployment originates from an appropriate source concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false jobs: # Runs on: # - ✅ Open/Updated PR - Always runs to load configuration - # - ✅ Merged PR - Always runs to load configuration - # - ✅ Abandoned PR - Always runs to load configuration + # - ✅ Default push - Always runs to load configuration + # - ✅ Closed PR - Always runs to load configuration # - ✅ Manual run - Always runs to load configuration Plan: uses: ./.github/workflows/Plan.yml @@ -101,8 +101,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Lints code changes in active PRs - # - ❌ Merged PR - No need to lint after merge + its a merge commit that causes issues with super-linter - # - ❌ Abandoned PR - No need to lint abandoned changes + # - ❌ Default push - No need to lint after merge + its a merge commit that causes issues with super-linter + # - ❌ Closed PR - No need to lint closed changes # - ❌ Manual run - Only runs for PR events Lint-Repository: if: fromJson(needs.Plan.outputs.Settings).Linter.Repository.Enabled @@ -114,8 +114,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Builds module for testing - # - ✅ Merged PR - Builds module for publishing - # - ❌ Abandoned PR - Skips building abandoned changes + # - ✅ Default push - Builds module for publishing + # - ❌ Closed PR - Skips building closed changes # - ✅ Manual run - Builds module when manually triggered Build-Module: if: fromJson(needs.Plan.outputs.Settings).Build.Module.Enabled @@ -130,8 +130,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Tests source code changes - # - ✅ Merged PR - Tests source code before publishing - # - ❌ Abandoned PR - Skips testing abandoned changes + # - ✅ Default push - Tests source code before publishing + # - ❌ Closed PR - Skips testing closed changes # - ✅ Manual run - Tests source code when manually triggered Test-SourceCode: if: fromJson(needs.Plan.outputs.Settings).Test.SourceCode.Enabled @@ -143,8 +143,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Lints source code changes - # - ✅ Merged PR - Lints source code before publishing - # - ❌ Abandoned PR - Skips linting abandoned changes + # - ✅ Default push - Lints source code before publishing + # - ❌ Closed PR - Skips linting closed changes # - ✅ Manual run - Lints source code when manually triggered Lint-SourceCode: if: fromJson(needs.Plan.outputs.Settings).Linter.SourceCode.Enabled @@ -156,8 +156,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Tests built module - # - ✅ Merged PR - Tests built module before publishing - # - ❌ Abandoned PR - Skips testing abandoned changes + # - ✅ Default push - Tests built module before publishing + # - ❌ Closed PR - Skips testing closed changes # - ✅ Manual run - Tests built module when manually triggered Test-Module: if: fromJson(needs.Plan.outputs.Settings).Test.PSModule.Enabled && needs.Build-Module.result == 'success' && !cancelled() @@ -170,8 +170,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Runs setup scripts before local module tests - # - ✅ Merged PR - Runs setup scripts before local module tests - # - ❌ Abandoned PR - Skips setup for abandoned changes + # - ✅ Default push - Runs setup scripts before local module tests + # - ❌ Closed PR - Skips setup for closed changes # - ✅ Manual run - Runs setup scripts when manually triggered BeforeAll-ModuleLocal: if: fromJson(needs.Plan.outputs.Settings).Test.Module.BeforeAllEnabled && needs.Build-Module.result == 'success' && !cancelled() @@ -186,8 +186,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Tests module in local environment - # - ✅ Merged PR - Tests module in local environment before publishing - # - ❌ Abandoned PR - Skips testing abandoned changes + # - ✅ Default push - Tests module in local environment before publishing + # - ❌ Closed PR - Skips testing closed changes # - ✅ Manual run - Tests module in local environment when manually triggered Test-ModuleLocal: if: fromJson(needs.Plan.outputs.Settings).Test.Module.MainEnabled && needs.Build-Module.result == 'success' && !cancelled() @@ -203,8 +203,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Runs teardown scripts after local module setup/tests - # - ✅ Merged PR - Runs teardown scripts after local module setup/tests - # - ✅ Abandoned PR - Runs teardown if local module setup/tests were started (cleanup) + # - ✅ Default push - Runs teardown scripts after local module setup/tests + # - ❌ Closed PR - No test setup ran in the closed-PR cleanup path # - ✅ Manual run - Runs teardown scripts after local module setup/tests AfterAll-ModuleLocal: if: fromJson(needs.Plan.outputs.Settings).Test.Module.AfterAllEnabled && needs.BeforeAll-ModuleLocal.result != 'skipped' && always() @@ -220,8 +220,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Collects and reports test results - # - ✅ Merged PR - Collects and reports test results before publishing - # - ❌ Abandoned PR - Skips collecting results for abandoned changes + # - ✅ Default push - Collects and reports test results before publishing + # - ❌ Closed PR - Skips collecting results for closed changes # - ✅ Manual run - Collects and reports test results when manually triggered Get-TestResults: if: fromJson(needs.Plan.outputs.Settings).Test.TestResults.Enabled && needs.Plan.result == 'success' && always() && !cancelled() @@ -239,8 +239,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Calculates and reports code coverage - # - ✅ Merged PR - Calculates and reports code coverage before publishing - # - ❌ Abandoned PR - Skips coverage for abandoned changes + # - ✅ Default push - Calculates and reports code coverage before publishing + # - ❌ Closed PR - Skips coverage for closed changes # - ✅ Manual run - Calculates and reports code coverage when manually triggered Get-CodeCoverage: if: fromJson(needs.Plan.outputs.Settings).Test.CodeCoverage.Enabled && needs.Plan.result == 'success' && always() && !cancelled() @@ -254,9 +254,9 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Only with prerelease label: publishes prerelease version - # - ✅ Merged PR - To default branch only: publishes release when all tests/coverage/build succeed - # - ✅ Abandoned PR - Cleans up prereleases for the abandoned branch (no version published) - # - ❌ Manual run - Only runs for PR events + # - ✅ 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 Publish-Module: if: fromJson(needs.Plan.outputs.Settings).Publish.Module.Enabled && needs.Plan.result == 'success' && !cancelled() && (needs.Get-TestResults.result == 'success' || needs.Get-TestResults.result == 'skipped') && (needs.Get-CodeCoverage.result == 'success' || needs.Get-CodeCoverage.result == 'skipped') && (needs.Build-Site.result == 'success' || needs.Build-Site.result == 'skipped') uses: ./.github/workflows/Publish-Module.yml @@ -274,8 +274,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Builds documentation for review - # - ✅ Merged PR - Builds documentation for publishing - # - ❌ Abandoned PR - Skips building docs for abandoned changes + # - ✅ Default push - Builds documentation for publishing + # - ❌ Closed PR - Skips building docs for closed changes # - ✅ Manual run - Builds documentation when manually triggered Build-Docs: if: fromJson(needs.Plan.outputs.Settings).Build.Docs.Enabled @@ -288,8 +288,8 @@ jobs: # Runs on: # - ✅ Open/Updated PR - Builds site for preview - # - ✅ Merged PR - Builds site for publishing - # - ❌ Abandoned PR - Skips building site for abandoned changes + # - ✅ Default push - Builds site for publishing + # - ❌ Closed PR - Skips building site for closed changes # - ✅ Manual run - Builds site when manually triggered Build-Site: if: fromJson(needs.Plan.outputs.Settings).Build.Site.Enabled @@ -302,9 +302,9 @@ jobs: # Runs on: # - ❌ Open/Updated PR - Site not published for PRs in progress - # - ✅ Merged PR - To default branch only: deploys site to GitHub Pages - # - ❌ Abandoned PR - Site not published for abandoned changes - # - ❌ Manual run - Only publishes on merged PRs to default branch + # - ✅ Default push - Deploys site to GitHub Pages unless publication is skipped + # - ❌ Closed PR - Site not published for closed changes + # - ✅ Manual run - Publishes from a default-branch stable release Publish-Site: if: fromJson(needs.Plan.outputs.Settings).Publish.Site.Enabled && needs.Get-TestResults.result == 'success' && needs.Get-CodeCoverage.result == 'success' && needs.Build-Site.result == 'success' && !cancelled() uses: ./.github/workflows/Publish-Site.yml From 5f316b81adefaf4d9e7907d7c596ea7e736fbdb3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:32:16 +0200 Subject: [PATCH 09/14] Document push-authoritative releases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/content/get-started/index.md | 4 +- docs/content/get-started/module-bootstrap.md | 2 +- docs/content/get-started/repository-setup.md | 2 +- .../content/get-started/your-first-release.md | 27 ++++++---- docs/content/guides/calling-the-workflow.md | 6 ++- docs/content/index.md | 9 +++- docs/content/reference/pipeline-stages.md | 5 +- .../reference/powershell-module-standard.md | 47 +++++++++------- docs/content/reference/scenario-matrix.md | 53 ++++++++++--------- docs/content/reference/settings.md | 6 +-- docs/content/specification/design.md | 15 +++--- docs/content/specification/spec.md | 17 +++--- 12 files changed, 118 insertions(+), 75 deletions(-) diff --git a/docs/content/get-started/index.md b/docs/content/get-started/index.md index b641b85d..72cc182b 100644 --- a/docs/content/get-started/index.md +++ b/docs/content/get-started/index.md @@ -15,7 +15,7 @@ Start new modules from the PSModule template repository: 3. Replace placeholder metadata and remove scaffold sample files. 4. Add your first public command and tests. 5. Validate `.github/PSModule.yml` defaults for your module. -6. [Open a draft pull request](your-first-release.md) and run the full pipeline. +6. [Open a draft pull request](your-first-release.md), then release from its resulting important default-branch push. If the module needs several interdependent commands before it is usable at all, see [Module bootstrap](module-bootstrap.md) instead of shipping them as one command per step. @@ -31,7 +31,7 @@ If the module needs several interdependent commands before it is usable at all, | Page | Description | | --- | --- | | [Repository setup](repository-setup.md) | GitHub Pages, `PSGALLERY_API_KEY`, permissions, and the caller workflow. | -| [Your first release](your-first-release.md) | The pull request flow, version labels, and what happens on merge. | +| [Your first release](your-first-release.md) | The pull request flow, version labels, and the resulting default-branch release. | | [Module bootstrap](module-bootstrap.md) | Getting a brand-new module to its first release with an integration branch. | For framework-level practices, refer to [MSX Ways of Working](https://msx.no/docs/Ways-of-Working/). diff --git a/docs/content/get-started/module-bootstrap.md b/docs/content/get-started/module-bootstrap.md index 496234e6..1ee1a93e 100644 --- a/docs/content/get-started/module-bootstrap.md +++ b/docs/content/get-started/module-bootstrap.md @@ -20,7 +20,7 @@ Scope the integration branch to exactly that core, not to everything planned for 1. Cut one long-lived branch from the default branch for the initial release, named for the outcome, e.g. `build-thing-module`. 2. Open one pull request per function (or small group of related functions) targeting that branch instead of `main`. These PRs can land in parallel — there is no strict order between them, unlike a [stacked pull request](https://msx.no/docs/Ways-of-Working/Branching-and-Merging/#stacked-pull-requests). -3. Once the load-bearing core is coherent and complete, open the pull request that merges the integration branch into `main`. This becomes the module's first real release (`v1.0.0`). +3. Once the load-bearing core is coherent and complete, open the pull request that merges the integration branch into `main`. Its resulting important push becomes the module's first real release (`v1.0.0`). 4. Smaller follow-up features (one more function, a formatter, an alias) can keep targeting the integration branch before it lands, the same way they targeted it during bootstrap. ## After the core lands diff --git a/docs/content/get-started/repository-setup.md b/docs/content/get-started/repository-setup.md index 83cb5238..d8fbabaa 100644 --- a/docs/content/get-started/repository-setup.md +++ b/docs/content/get-started/repository-setup.md @@ -53,7 +53,7 @@ on: - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false permissions: diff --git a/docs/content/get-started/your-first-release.md b/docs/content/get-started/your-first-release.md index 2122a984..e8b00fb1 100644 --- a/docs/content/get-started/your-first-release.md +++ b/docs/content/get-started/your-first-release.md @@ -1,12 +1,13 @@ --- title: Your first release -description: The pull request flow, version labels, and what happens when a Process-PSModule pull request is merged. +description: The pull request flow, version labels, and the default-branch push that creates a stable Process-PSModule release. --- # Your first release -Process-PSModule is driven entirely by pull requests. There is no manual publish step, no version file to edit, and no -tag to push by hand. +Process-PSModule uses pull requests for review and release metadata, and an important push to the default branch as +the authority for stable publication. There is no manual publish step, no version file to edit, and no tag to push by +hand. ## The flow @@ -14,9 +15,15 @@ tag to push by hand. 2. Push the branch and open a pull request against `main`. 3. The workflow builds the module, runs tests on Windows, Linux, and macOS, lints the repository, and reports back on the pull request. -4. Apply a version label to declare release intent (see below). Without a label, the change releases as a **patch**. -5. Merge the pull request. The workflow publishes the module to the PowerShell Gallery, creates a GitHub Release and - tag, and deploys the documentation site to GitHub Pages. +4. Apply a version label to declare release intent (see below). An unlabeled pull request defaults to a **patch** when + `Publish.Module.AutoPatching` is enabled, which is the default. +5. Merge the pull request. Its resulting important push to `main` runs the stable release: after the pipeline passes, + it publishes the module to the PowerShell Gallery, creates a GitHub Release and tag for the tested commit, and + deploys the documentation site unless site publication is configured to skip. The closed-pull-request event only + cleans up prereleases. + +An important direct push to the default branch, or a manual dispatch on that branch, also creates a stable **patch** +release with commit-based notes. It has no pull-request labels or body to use as metadata. ## Version labels @@ -24,7 +31,7 @@ tag to push by hand. | --- | --- | | `major` / `breaking` | Bump `MAJOR`. | | `minor` / `feature` | Bump `MINOR`. | -| `patch` / `fix` | Bump `PATCH`. This is the default when no label is applied. | +| `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. | @@ -43,9 +50,9 @@ cleaned up automatically. ## When nothing is released -If a pull request only touches files outside the configured important-file patterns — documentation, CI tweaks, comment -typos — the build, test, and publish stages are skipped and no release is created. A comment on the pull request -explains why. See +If a pull request or default-branch push only touches files outside the configured important-file patterns — +documentation, CI tweaks, comment typos — the build, test, and publish stages are skipped and no release is created. +A pull-request comment explains why for pull-request runs. See [important-file change detection](../guides/calling-the-workflow.md#important-file-change-detection) to change which paths trigger a release. diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index 567f968a..c9390839 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -36,7 +36,7 @@ on: - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false permissions: @@ -61,6 +61,10 @@ Stable releases are evaluated from a push to the default branch. A merged pull r release notes; a direct default-branch push or a manual dispatch uses the default `Patch` bump and commit-based notes. Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. +The concurrency key keeps a pull request distinct from a default-branch push, so the close-event cleanup and the +resulting stable release do not serialize as one run. Keep `cancel-in-progress: false`: a release-capable run mutates +the PowerShell Gallery, GitHub Releases, and tags, so later runs must queue rather than interrupt it. + ## Passing test data The reusable workflow at `.github/workflows/workflow.yml` declares four workflow-call secrets, diff --git a/docs/content/index.md b/docs/content/index.md index 0a63f3b5..57fe5412 100644 --- a/docs/content/index.md +++ b/docs/content/index.md @@ -9,7 +9,11 @@ An end-to-end PowerShell module pipeline that automates the entire lifecycle of ## How it works -The workflow is triggered on pull requests to the repository's default branch. When a pull request is opened, closed, reopened, synchronized (push), or labeled, the workflow runs. Depending on the labels on the pull request, the [workflow results in different outcomes](reference/scenario-matrix.md). +The caller workflow runs for pull-request lifecycle events and pushes to the repository's default branch. Open pull +requests run CI and can publish prereleases; closed pull requests clean up their prereleases. An important +default-branch push is the sole authority for a stable release. When that push is the exact merge commit of a pull +request, its labels and release notes supply the release metadata. See the +[scenario matrix](reference/scenario-matrix.md) for the resulting job execution. Everything is packaged into a single reusable workflow so that a module repository only needs a small caller workflow and one settings file. A user configures the behaviour by editing `.github/PSModule.yml`. @@ -23,7 +27,7 @@ New to Process-PSModule? Work through these in order. | --- | --- | | [Get started](get-started/index.md) | Create a module repository from the template and get the pipeline running. | | [Repository setup](get-started/repository-setup.md) | Configure GitHub Pages, `PSGALLERY_API_KEY`, permissions, and the caller workflow. | -| [Your first release](get-started/your-first-release.md) | The pull request flow, version labels, and what happens on merge. | +| [Your first release](get-started/your-first-release.md) | The pull request flow, version labels, and the resulting default-branch release. | ## Guides @@ -32,6 +36,7 @@ Task-oriented deep dives into the pipeline's functionality. | Page | Description | | --- | --- | | [Calling the workflow](guides/calling-the-workflow.md) | The caller workflow, passing test secrets and variables with `TestData`, and important-file change detection. | +| [GitHub App authentication](guides/github-app-authentication.md) | Configure Shelly credentials and the scoped tokens used for repository API operations. | | [Configuring the pipeline](guides/configuring-the-pipeline.md) | Worked examples for coverage targets, rapid testing, linting, and PR-based release notes. | | [Structuring your module](guides/structuring-your-module.md) | The repository and module source layout the workflow expects, and how to declare dependencies. | | [Writing module tests](guides/writing-module-tests.md) | Test discovery, setup and teardown phases, and shared test infrastructure. | diff --git a/docs/content/reference/pipeline-stages.md b/docs/content/reference/pipeline-stages.md index d9a6e547..12368953 100644 --- a/docs/content/reference/pipeline-stages.md +++ b/docs/content/reference/pipeline-stages.md @@ -132,8 +132,11 @@ How to write these tests, including the Pester version requirement and shared-in [workflow](https://github.com/PSModule/Process-PSModule/blob/main/.github/workflows/Publish-Module.yml) +- An important default-branch push is the only stable-publication authority. A closed pull request performs + prerelease cleanup only. - Publishes the artifact to the PowerShell Gallery exactly as built — no version mutation. -- Creates a GitHub Release using the version already stamped in the manifest. +- Creates a GitHub Release only after the Gallery publication succeeds, targeting the exact tested push SHA and using + the version already stamped in the manifest. - Attaches the built module as a `.zip` asset on the GitHub Release so consumers can download the exact bytes that were tested and pushed to the PowerShell Gallery. - **Abandoned PR cleanup**: When a PR is closed without merging (abandoned), the workflow automatically cleans up any prerelease versions and tags that were created for that PR. This ensures that abandoned work doesn't leave orphaned diff --git a/docs/content/reference/powershell-module-standard.md b/docs/content/reference/powershell-module-standard.md index 749310b0..6b9bd2a4 100644 --- a/docs/content/reference/powershell-module-standard.md +++ b/docs/content/reference/powershell-module-standard.md @@ -111,15 +111,20 @@ Keep related things together so the connection between code and its context is v ### Linear versioning -The release process treats each merged PR as a release on a single linear ancestry. There is no patching of older versions — security fixes go on the current tip of `main` only. +The release process treats each important default-branch push as a release on a single linear ancestry. A merged pull +request supplies release metadata when its merge commit exactly matches that push. There is no patching of older +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 the final merge to `main`. +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. ## CI/CD pipeline -The [Process-PSModule](https://github.com/PSModule/Process-PSModule) workflow orchestrates the full lifecycle. Every PR triggers a **Plan** job that resolves configuration and version, then conditionally runs build, test, lint, and publish stages. +The [Process-PSModule](https://github.com/PSModule/Process-PSModule) workflow orchestrates the full lifecycle. Pull +requests and default-branch pushes trigger a **Plan** job that resolves configuration and version, then conditionally +runs build, test, lint, and publish stages. ### Pipeline stages @@ -147,20 +152,20 @@ graph LR | Stage | Runs on | Purpose | | ----- | ------- | ------- | -| **Plan** | All events | Loads `.github/PSModule.yml`, resolves version from PR labels, produces the Settings JSON | +| **Plan** | All events | Loads `.github/PSModule.yml`, resolves the release context, produces the Settings JSON | | **Lint-Repository** | Open/Updated PR | Runs super-linter on the full repo (Markdown, YAML, etc.) | -| **Lint-SourceCode** | Open/Updated PR, Merged PR, Manual | Runs PSScriptAnalyzer against `src/` | -| **Build-Module** | Open/Updated PR, Merged PR, Manual | Compiles source into a versioned module artifact | -| **Test-SourceCode** | Open/Updated PR, Merged PR, Manual | Framework tests on raw source files | -| **Test-Module** | Open/Updated PR, Merged PR, Manual | Pester tests against the built module artifact | -| **BeforeAll-ModuleLocal** | Open/Updated PR, Merged PR, Manual | Runs `tests/BeforeAll.ps1` once before the local test matrix | -| **Test-ModuleLocal** | Open/Updated PR, Merged PR, Manual | Pester tests with the module installed locally (cross-OS matrix) | +| **Lint-SourceCode** | Open/Updated PR, default-branch push/manual run | Runs PSScriptAnalyzer against `src/` | +| **Build-Module** | Open/Updated PR, default-branch push/manual run | Compiles source into a versioned module artifact | +| **Test-SourceCode** | Open/Updated PR, default-branch push/manual run | Framework tests on raw source files | +| **Test-Module** | Open/Updated PR, default-branch push/manual run | Pester tests against the built module artifact | +| **BeforeAll-ModuleLocal** | Open/Updated PR, default-branch push/manual run | Runs `tests/BeforeAll.ps1` once before the local test matrix | +| **Test-ModuleLocal** | Open/Updated PR, default-branch push/manual run | Pester tests with the module installed locally (cross-OS matrix) | | **AfterAll-ModuleLocal** | Always (if tests started) | Runs `tests/AfterAll.ps1` for cleanup | | **Get-TestResults** | Always (if Plan succeeded) | Aggregates and reports test results | | **Get-CodeCoverage** | Always (if Plan succeeded) | Calculates and reports code coverage | -| **Publish-Module** | Merged PR (or Prerelease label) | Publishes to PowerShell Gallery and creates a GitHub Release | -| **Build-Docs / Build-Site** | Open/Updated PR, Merged PR, Manual | Generates documentation site from source | -| **Publish-Site** | Merged PR | Deploys documentation site to GitHub Pages | +| **Publish-Module** | Prerelease PR, stable default-branch push/manual run, or closed PR | Publishes a prerelease or stable release, or cleans up closed-PR prereleases | +| **Build-Docs / Build-Site** | Open/Updated PR, default-branch push/manual run | Generates documentation site from source | +| **Publish-Site** | Stable default-branch push/manual run | Deploys documentation site to GitHub Pages unless `Publish.Site.Skip` is set | ### Important file patterns @@ -186,7 +191,7 @@ The **Plan** job resolves the next version before any build occurs. This means t **Flow:** -1. `Get-PSModuleSettings` loads `.github/PSModule.yml` and determines `ReleaseType` from PR labels +1. `Get-PSModuleSettings` loads `.github/PSModule.yml` and determines `ReleaseType` from the normalized event context 2. `Resolve-PSModuleVersion` calculates the next semantic version from the latest Git tag 3. `Build-PSModule` stamps the resolved version into the compiled manifest 4. `Publish-PSModule` reads the version from the manifest (read-only) and publishes @@ -203,6 +208,9 @@ The **Plan** job resolves the next version before any build occurs. This means t **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). +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. + **Tag format:** Releases are tagged with a configurable prefix (default `v`) — e.g., `v1.2.3`. ### Configuration (`.github/PSModule.yml`) @@ -246,9 +254,11 @@ Test: PercentTarget: 0 Publish: + Site: + Skip: false Module: Skip: false - AutoCleanup: true # Delete prerelease tags after stable release + AutoCleanup: true # Delete prerelease tags after stable release or PR closure AutoPatching: true # Unlabeled PRs default to patch bump IncrementalPrerelease: true # Sequential prerelease numbering DatePrereleaseFormat: '' # Alternative: .NET DateTime format for prerelease @@ -274,16 +284,17 @@ The `Publish-Module` stage: 2. Reads the version from the compiled manifest (no recalculation) 3. Publishes to the PowerShell Gallery 4. Creates a GitHub Release with the module attached as a ZIP artifact -5. Comments on the PR with links to the Gallery package and GitHub Release +5. Comments on the associated PR with links to the Gallery package and GitHub Release 6. Cleans up old prerelease tags when publishing a stable release (if `AutoCleanup: true`) The publish step only runs when: - All tests and code coverage pass (or are skipped) -- The PR is merged to the default branch (stable release), or +- An important push reaches the default branch (stable release), or - The PR carries the `Prerelease` label (prerelease from the feature/release branch) -On abandoned (closed without merge) PRs, the pipeline cleans up any prerelease tags created for that branch. +On any closed PR, the pipeline cleans up any prerelease tags created for that branch. A closed pull request cannot +create a stable release. ## Tests diff --git a/docs/content/reference/scenario-matrix.md b/docs/content/reference/scenario-matrix.md index 4eb80cc3..8a5a3d1b 100644 --- a/docs/content/reference/scenario-matrix.md +++ b/docs/content/reference/scenario-matrix.md @@ -1,6 +1,6 @@ --- title: Scenario matrix -description: Which Process-PSModule jobs run for each trigger scenario — open pull request, merged pull request, abandoned pull request, and manual run. +description: Which Process-PSModule jobs run for each trigger scenario — open pull request, default-branch push, closed pull request, and default-branch manual run. --- # Scenario matrix @@ -8,31 +8,36 @@ description: Which Process-PSModule jobs run for each trigger scenario — open This table shows when each job runs based on the trigger scenario. It is the single source of truth for job execution; other pages link here rather than repeating it. -| Job | Open/Updated PR | Merged PR | Abandoned PR | Manual Run | -| ------------------------- | --------------- | ---------- | ------------ | ---------- | -| **Plan** | ✅ Always | ✅ Always | ✅ Always | ✅ Always | -| **Lint-Repository** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Build-Module** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Build-Docs** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Build-Site** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Test-SourceCode** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Lint-SourceCode** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Test-Module** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **BeforeAll-ModuleLocal** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Test-ModuleLocal** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **AfterAll-ModuleLocal** | ✅ Yes | ✅ Yes | ✅ Yes* | ✅ Yes | -| **Get-TestResults** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Get-CodeCoverage** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | -| **Publish-Site** | ❌ No | ✅ Yes | ❌ No | ❌ No | -| **Publish-Module** | ✅ Yes** | ✅ Yes** | ✅ Yes*** | ✅ Yes** | +| Job | Open/Updated PR | Default-branch push | Closed PR | Default-branch manual run | +| ------------------------- | --------------- | ------------------- | --------- | ------------------------- | +| **Plan** | ✅ Always | ✅ Always | ✅ Always | ✅ Always | +| **Lint-Repository** | ✅ Yes | ❌ No | ❌ No | ❌ No | +| **Build-Module** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Build-Docs** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Build-Site** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Test-SourceCode** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Lint-SourceCode** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Test-Module** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **BeforeAll-ModuleLocal** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Test-ModuleLocal** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **AfterAll-ModuleLocal** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Get-TestResults** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Get-CodeCoverage** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | +| **Publish-Site** | ❌ No | ✅ Yes* | ❌ No | ✅ Yes* | +| **Publish-Module** | ✅ Prerelease† | ✅ Stable† | ✅ Cleanup‡ | ✅ Stable† | -- \* Runs for cleanup if tests were started -- \*\* Only when all tests/coverage/build succeed -- \*\*\* Cleans up prerelease versions and tags created for the abandoned PR (when `Publish.Module.AutoCleanup` is - enabled) +- \* 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 + 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; + it does not publish a stable release. -A job that is enabled by this matrix can still be skipped by a setting (for example `Test.Skip`) or because the pull -request changed no [important files](../guides/calling-the-workflow.md#important-file-change-detection). +A job that is enabled by this matrix can still be skipped by a setting (for example `Test.Skip`) or because an open PR +or default-branch push changed no +[important files](../guides/calling-the-workflow.md#important-file-change-detection). Default-branch manual dispatch +runs are intentionally treated as important. ## Related diff --git a/docs/content/reference/settings.md b/docs/content/reference/settings.md index 2f39ae23..c2121220 100644 --- a/docs/content/reference/settings.md +++ b/docs/content/reference/settings.md @@ -58,10 +58,10 @@ For worked examples, see [Configuring the pipeline](../guides/configuring-the-pi | `Build.Docs.Skip` | `Boolean` | Skip documentation build | `false` | | `Build.Docs.ShowSummaryOnSuccess` | `Boolean` | Show super-linter summary on success for documentation linting | `false` | | `Build.Site.Skip` | `Boolean` | Skip site build | `false` | -| `Publish.Site.Skip` | `Boolean` | Skip publishing the generated documentation site | `false` | +| `Publish.Site.Skip` | `Boolean` | Skip deployment of the generated documentation site while retaining the site build and artifact | `false` | | `Publish.Module.Skip` | `Boolean` | Skip module publishing | `false` | -| `Publish.Module.AutoCleanup` | `Boolean` | Automatically clean up old prerelease tags when merging to main or when a PR is abandoned | `true` | -| `Publish.Module.AutoPatching` | `Boolean` | Automatically patch module version | `true` | +| `Publish.Module.AutoCleanup` | `Boolean` | Automatically clean up old prerelease tags after a stable default-branch release or when a PR is abandoned | `true` | +| `Publish.Module.AutoPatching` | `Boolean` | Default an unlabeled pull-request release to `Patch`; direct default-branch releases are always `Patch` | `true` | | `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'` | diff --git a/docs/content/specification/design.md b/docs/content/specification/design.md index 27d903b3..521b71b7 100644 --- a/docs/content/specification/design.md +++ b/docs/content/specification/design.md @@ -47,10 +47,10 @@ That enriched object is an internal inter-workflow contract, not an authoring fo ## Scenario matrix -Release intent comes from pull-request labels and is resolved once, in the Plan job. A default-branch push resolves the -merged pull request for its labels, while a direct push or manual dispatch defaults to `Patch` regardless of -`AutoPatching`. The label-to-bump -mapping, handling of conflicting labels, and branch types that may publish are documented in +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). Tests run on **Windows** (latest), **Linux** (Ubuntu latest), and **macOS** (latest). Failures on any platform block @@ -90,9 +90,12 @@ Settings live only as workflow outputs, computed by Plan. Pros: single source of ### Version computation -**Chosen: PR label + current version** +**Chosen: default-branch push + current version** -The bump comes from the PR label; the next version is computed as `current_version + bump`. Pros: explicit, git-traceable (the label is recorded in the PR). Cons: must be re-computed if a PR is re-run or the base version changes. +An important default-branch push is the release authority. When its commit exactly matches a merged pull request, the +bump comes from that PR label; otherwise it is `Patch`. The next version is computed from the current version. Pros: +explicit, git-traceable release metadata without making a pull-request close event a publication authority. Cons: it +must be re-computed if a PR is re-run or the base version changes. **Alternative: Conventional Commits** diff --git a/docs/content/specification/spec.md b/docs/content/specification/spec.md index 292f2e3a..637bd4c9 100644 --- a/docs/content/specification/spec.md +++ b/docs/content/specification/spec.md @@ -43,7 +43,11 @@ 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. It MUST compute the next version automatically, never reading or writing a hand-edited version file. A 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 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. ### FR6 — Produce immutable, linkable releases { #fr6 } @@ -78,7 +82,7 @@ The entire pipeline and its decisions MUST be stored in git, so the build is rep ```gherkin Scenario: Merge a valid pull request to main Given a pull request with passing tests and quality gates - When the PR is merged to main + When its merge commit is pushed to main Then the module is built And all tests pass on all configured platforms And code coverage meets the configured threshold @@ -87,9 +91,10 @@ Scenario: Merge a valid pull request to main ### Version computation ```gherkin -Scenario: Compute the next version from the PR label +Scenario: Compute the next version from the merged PR label Given a pull request with the label "Minor" - When the PR is merged to main and the current version is v1.2.3 + 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 @@ -103,7 +108,7 @@ Scenario: Reject ambiguous version labels ```gherkin Scenario: Publish a module after a stable release Given a merged PR to main with a version bump label - When the build completes successfully + When its merge commit is pushed to main and the build completes successfully Then a new version is published to the PowerShell Gallery And a GitHub Release is created And a git tag is pushed @@ -121,7 +126,7 @@ Scenario: Publish a prerelease version Scenario: Promote a prerelease to stable Given a prerelease PR that is merged to main with a version label - When the PR is merged + When its merge commit is pushed to main Then a stable version is computed (e.g., v1.3.0) based on the label and the current main version And the stable version is published ``` From 6aac86d1db3f6fb653723a0728db472766e34672 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 11:47:48 +0200 Subject: [PATCH 10/14] Guard closed pull request label events Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 14 +++++++++++--- .github/actions/Get-PSModuleSettings/src/main.ps1 | 4 ++++ .../tests/Get-PSModuleSettings.Helpers.Tests.ps1 | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index c5825544..7a588580 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -15,6 +15,9 @@ function Resolve-WorkflowEventRouting { [Parameter()] [bool] $PullRequestIsMerged, + [Parameter()] + [bool] $PullRequestIsClosed, + [Parameter()] [bool] $IsTargetDefaultBranch, @@ -34,8 +37,9 @@ function Resolve-WorkflowEventRouting { $isPR = $EventName -eq 'pull_request' $isPush = $EventName -eq 'push' $isManualDispatch = $EventName -eq 'workflow_dispatch' - $isOpenOrUpdatedPR = $isPR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') - $isOpenOrLabeledPR = $isPR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled') + $isActivePR = $isPR -and -not $PullRequestIsClosed + $isOpenOrUpdatedPR = $isActivePR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') + $isOpenOrLabeledPR = $isActivePR -and $EventAction -in @('opened', 'reopened', 'synchronize', 'labeled') $isClosedPR = $isPR -and $EventAction -eq 'closed' $isAbandonedPR = $isClosedPR -and -not $PullRequestIsMerged $isMergedPR = $isClosedPR -and $PullRequestIsMerged @@ -65,7 +69,11 @@ function Resolve-WorkflowEventRouting { } else { 'None' } - ShouldRunBuildTest = (-not $isClosedPR) -and $HasImportantChanges + ShouldRunBuildTest = ( + -not $isClosedPR -and + ((-not $isPR) -or (-not $PullRequestIsClosed)) -and + $HasImportantChanges + ) ShouldCleanupEvent = $isClosedPR } } diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 475ee8de..4d3949c2 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -267,6 +267,7 @@ LogGroup 'Calculate Job Run Conditions:' { } else { -not [string]::IsNullOrWhiteSpace($pullRequest.merged_at) } + $pullRequestIsClosed = $null -ne $pullRequest -and $pullRequest.State -eq 'closed' $targetBranch = if ($pullRequest) { $pullRequest.Base.Ref } elseif ($isPush) { $pushBranch } else { $workflowRef } $isTargetDefaultBranch = $targetBranch -eq $defaultBranch $pullRequestContext = if ($pullRequest) { @@ -278,6 +279,7 @@ LogGroup 'Calculate Job Run Conditions:' { BaseRef = $pullRequest.Base.Ref Labels = @($pullRequest.Labels.Name) Merged = $pullRequestIsMerged + Closed = $pullRequestIsClosed MergeCommitSha = $pullRequest.merge_commit_sha HtmlUrl = $pullRequest.html_url } @@ -301,6 +303,7 @@ LogGroup 'Calculate Job Run Conditions:' { GITHUB_EVENT_NAME = $eventName GITHUB_EVENT_ACTION = $pullRequestAction GITHUB_EVENT_PULL_REQUEST_MERGED = $pullRequestIsMerged + GITHUB_EVENT_PULL_REQUEST_CLOSED = $pullRequestIsClosed CommitSha = $commitSha PushBranch = $pushBranch TargetBranch = $targetBranch @@ -439,6 +442,7 @@ If you believe this is incorrect, please verify that your changes are in the cor $routing = Resolve-WorkflowEventRouting -EventName $eventName ` -EventAction $pullRequestAction ` -PullRequestIsMerged $pullRequestIsMerged ` + -PullRequestIsClosed $pullRequestIsClosed ` -IsTargetDefaultBranch $isTargetDefaultBranch ` -IsPushToDefaultBranch $isPushToDefaultBranch ` -IsManualDispatchToDefaultBranch $isManualDispatchToDefaultBranch ` 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 b1abdcc6..c6553184 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -63,6 +63,20 @@ Describe 'Resolve-WorkflowEventRouting' { $result.ShouldRunBuildTest | Should -BeFalse $result.ShouldCleanupEvent | Should -BeTrue } + + It 'does not run a label event for a closed PR' { + $result = Resolve-WorkflowEventRouting -EventName pull_request ` + -EventAction labeled ` + -PullRequestIsClosed $true ` + -IsTargetDefaultBranch $true ` + -HasImportantChanges $true ` + -HasPrereleaseLabel $true + + $result.ReleaseType | Should -Be 'None' + $result.IsOpenOrUpdatedPR | Should -BeFalse + $result.ShouldRunBuildTest | Should -BeFalse + $result.ShouldCleanupEvent | Should -BeFalse + } } Describe 'Select-PullRequestForPush' { From 4de61e03548b1724d8b2c070641f868fc45a1fec Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:12:03 +0200 Subject: [PATCH 11/14] Harden release workflow review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../actions/Get-PSModuleSettings/src/main.ps1 | 3 +++ .../actions/Release-PSModule/src/release.ps1 | 10 ++++++---- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 17 +++++++++++++++++ .github/workflows/workflow.yml | 2 +- docs/content/guides/calling-the-workflow.md | 2 ++ docs/content/guides/versioning-and-releases.md | 6 ++++-- 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 4d3949c2..d29fbb7f 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -268,6 +268,9 @@ LogGroup 'Calculate Job Run Conditions:' { -not [string]::IsNullOrWhiteSpace($pullRequest.merged_at) } $pullRequestIsClosed = $null -ne $pullRequest -and $pullRequest.State -eq 'closed' + $isOpenOrUpdatedPR = $eventName -eq 'pull_request' -and + -not $pullRequestIsClosed -and + $pullRequestAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') $targetBranch = if ($pullRequest) { $pullRequest.Base.Ref } elseif ($isPush) { $pushBranch } else { $workflowRef } $isTargetDefaultBranch = $targetBranch -eq $defaultBranch $pullRequestContext = if ($pullRequest) { diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index bc53dcd3..27c1b73c 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -40,10 +40,6 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'commitSha', Justification = 'Variable is used in script blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'commitMessage', - Justification = 'Variable is used in script blocks.' -)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'releaseType', Justification = 'Variable is used in script blocks.' @@ -112,6 +108,12 @@ LogGroup 'Load release context' { $prNumber = $pullRequest.Number $prHeadRef = $pullRequest.HeadRef $commitMessage = $githubEvent.head_commit.message + if ([string]::IsNullOrWhiteSpace($commitMessage) -and -not [string]::IsNullOrWhiteSpace($commitSha)) { + $commitMessage = git log -1 --format=%B $commitSha + if ($LASTEXITCODE -ne 0) { + throw "Failed to read commit message for [$commitSha]." + } + } if ($prNumber) { Write-Host "Pull request: [#$prNumber]" diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index 872fc691..5af24d8c 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -36,6 +36,7 @@ AfterAll { [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) } Remove-Item -Path function:global:gh -ErrorAction SilentlyContinue + Remove-Item -Path function:global:git -ErrorAction SilentlyContinue } Describe 'Release-PSModule WhatIf' { @@ -140,4 +141,20 @@ Describe 'Release-PSModule WhatIf' { Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3' $releaseOutput | Should -Match '--target pushed-commit-sha' } + + It 'uses the checked-out commit message for a manual dispatch' { + $manifest = Get-Content -Path $manifestPath -Raw + $manifest -replace "Prerelease = 'preview.1'", "Prerelease = ''" | Set-Content -Path $manifestPath + @{} | ConvertTo-Json | Set-Content -Path $eventPath + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = 'dispatched-commit-sha' + Set-Item -Path function:global:git -Value { + $global:LASTEXITCODE = 0 + 'Publish manual dispatch release' + } + + $releaseOutput = & $script:releaseScriptPath 6>&1 | Out-String + + $releaseOutput | Should -Match 'Using the pushed commit message as release notes' + } } diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index bd097a45..233de005 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -76,7 +76,7 @@ permissions: id-token: write # to verify the deployment originates from an appropriate source concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: Process-PSModule-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: false jobs: diff --git a/docs/content/guides/calling-the-workflow.md b/docs/content/guides/calling-the-workflow.md index c9390839..e9bb9e85 100644 --- a/docs/content/guides/calling-the-workflow.md +++ b/docs/content/guides/calling-the-workflow.md @@ -64,6 +64,8 @@ Keep the `pull_request` trigger for CI, prereleases, and prerelease cleanup. The concurrency key keeps a pull request distinct from a default-branch push, so the close-event cleanup and the resulting stable release do not serialize as one run. Keep `cancel-in-progress: false`: a release-capable run mutates the PowerShell Gallery, GitHub Releases, and tags, so later runs must queue rather than interrupt it. +The reusable workflow uses its own prefixed concurrency group, so it cannot queue behind the caller while the caller +waits for it to finish. ## Passing test data diff --git a/docs/content/guides/versioning-and-releases.md b/docs/content/guides/versioning-and-releases.md index 3e76124f..7cbe9cf1 100644 --- a/docs/content/guides/versioning-and-releases.md +++ b/docs/content/guides/versioning-and-releases.md @@ -45,8 +45,10 @@ The label names are configurable through `Publish.Module.MajorLabels`, `MinorLab ## Branch types - **Main (stable)** — pushes publish stable releases. A prerelease label on an open pull request publishes a prerelease. -- **Development** — optional prerelease branch (for example `dev`). Each push publishes a prerelease. -- **Feature branch** — optional feature branch. A prerelease label publishes a prerelease for testing. +- **Development** — optional prerelease branch (for example `dev`). Its open, prerelease-labelled pull request to the + stable branch publishes previews when it is updated. +- **Feature branch** — optional feature branch. Its open, prerelease-labelled pull request publishes a preview for + testing. Exactly one branch is authorized to publish stable releases, so consumers always have one unambiguous latest version. From a758ef48bbb7448f2950123e6f9ddfca73837b2b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:12:10 +0200 Subject: [PATCH 12/14] Resume Gallery-only stable publications Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../actions/Publish-PSModule/src/publish.ps1 | 11 ++- .../tests/Publish-PSModule.Recovery.Tests.ps1 | 87 +++++++++++++++++++ .../src/Resolve-PSModuleVersion.Helpers.psm1 | 80 +++++++++++++++++ .../Resolve-PSModuleVersion/src/main.ps1 | 6 +- .../Resolve-PSModuleVersion.Helpers.Tests.ps1 | 34 ++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 .github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 49a6fe3b..24f72289 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -145,11 +145,14 @@ LogGroup 'Publish to PSGallery' { if ($whatIf) { Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***" } else { - try { + $publishedPackage = Find-PSResource -Name $name -Version $publishPSVersion -Repository PSGallery -ErrorAction Stop + if ($publishedPackage) { + Write-Host ( + "::notice title=♻️ Resuming Gallery-only publication::$name $publishPSVersion is already " + + 'published to the PowerShell Gallery.' + ) + } else { Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey $psGalleryApiKey - } catch { - Write-Error $_.Exception.Message - exit 1 } } diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 new file mode 100644 index 00000000..57a9ecaa --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Recovery.Tests.ps1 @@ -0,0 +1,87 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' +)] +[CmdletBinding()] +param() + +BeforeAll { + Import-Module -Name 'PSModule' -Force + + $script:publishScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/publish.ps1' + $script:environmentVariableNames = @( + 'GITHUB_EVENT_PATH' + 'GITHUB_REPOSITORY' + 'GITHUB_WORKSPACE' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_Name' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest' + 'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf' + ) + $script:originalEnvironment = @{} + foreach ($name in $script:environmentVariableNames) { + $script:originalEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name) + } +} + +AfterAll { + foreach ($name in $script:environmentVariableNames) { + [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + } + Remove-Item -Path function:global:Find-PSResource -ErrorAction SilentlyContinue + Remove-Item -Path function:global:Publish-PSResource -ErrorAction SilentlyContinue + Remove-Item -Path function:global:Resolve-PSModuleDependency -ErrorAction SilentlyContinue +} + +Describe 'Publish-PSModule recovery' { + BeforeEach { + $script:moduleName = 'TestModule' + $script:workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace' + $script:modulePath = Join-Path -Path $script:workspacePath -ChildPath "outputs/module/$script:moduleName" + $manifestPath = Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psd1" + $eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json' + $null = New-Item -Path $script:modulePath -ItemType Directory -Force + Set-Content -Path (Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psm1") -Value '' + Set-Content -Path $manifestPath -Value @" +@{ + RootModule = '$script:moduleName.psm1' + ModuleVersion = '1.2.4' + GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' + Author = 'PSModule' + Description = 'Test module' + PowerShellVersion = '5.1' + PrivateData = @{ + PSData = @{ + Prerelease = '' + } + } +} +"@ + @{} | ConvertTo-Json | Set-Content -Path $eventPath + + $env:GITHUB_EVENT_PATH = $eventPath + $env:GITHUB_REPOSITORY = "PSModule/$script:moduleName" + $env:GITHUB_WORKSPACE = $script:workspacePath + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_Name = $script:moduleName + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath = 'outputs/module' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PSGALLERY_API_KEY = 'test-key' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_PullRequest = '' + $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false' + $script:publishInvoked = $false + + Set-Item -Path function:global:Resolve-PSModuleDependency -Value {} + Set-Item -Path function:global:Find-PSResource -Value { + [PSCustomObject]@{ Name = 'TestModule'; Version = '1.2.4' } + } + Set-Item -Path function:global:Publish-PSResource -Value { + $script:publishInvoked = $true + } + } + + It 'skips Gallery publication when the resolved version already exists' { + { & $script:publishScriptPath } | Should -Not -Throw + + $script:publishInvoked | Should -BeFalse + } +} diff --git a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 index a36a9383..ab9b8560 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 +++ b/.github/actions/Resolve-PSModuleVersion/src/Resolve-PSModuleVersion.Helpers.psm1 @@ -724,6 +724,86 @@ function Get-NextModuleVersion { } } +function Get-ResolvedModuleVersion { + <# + .SYNOPSIS + Resolves the next module version and resumes an incomplete stable release when possible. + + .DESCRIPTION + Normally the highest version from GitHub Releases and the PowerShell Gallery is the + version baseline. If Gallery contains exactly the stable version implied by the latest + GitHub release and this run's version bump, Gallery publication succeeded but GitHub + release creation did not. In that case, return the Gallery version rather than bumping + again so the workflow can resume the missing GitHub release. + + .OUTPUTS + PSSemVer representing the resolved module version. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', + Justification = 'Parameter is used inside LogGroup script block.')] + [CmdletBinding()] + [OutputType([object])] + param( + # The latest version found in GitHub Releases. + [Parameter(Mandatory)] + [object] $GitHubVersion, + + # The latest stable version found in the PowerShell Gallery. + [Parameter(Mandatory)] + [object] $PSGalleryVersion, + + # The release decision for this workflow run. + [Parameter(Mandatory)] + [PSCustomObject] $Decision, + + # The publish configuration object. + [Parameter(Mandatory)] + [PSCustomObject] $Configuration, + + # The name of the module. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleName, + + # The GitHub releases list, used for prerelease numbering. + [Parameter()] + [AllowNull()] + [AllowEmptyCollection()] + [array] $Releases = @() + ) + + LogGroup 'Resolve module version' { + $latestVersion = Get-LatestPublishedVersion -GitHubVersion $GitHubVersion -PSGalleryVersion $PSGalleryVersion + $params = @{ + LatestVersion = $latestVersion + Decision = $Decision + Configuration = $Configuration + ModuleName = $ModuleName + Releases = $Releases + } + $resolvedVersion = Get-NextModuleVersion @params + + if ($Decision.CreateRelease) { + $githubParams = $params.Clone() + $githubParams.LatestVersion = $GitHubVersion + $githubCandidate = Get-NextModuleVersion @githubParams + $galleryVersionString = "$($PSGalleryVersion.Major).$($PSGalleryVersion.Minor).$($PSGalleryVersion.Patch)" + $githubCandidateString = "$($githubCandidate.Major).$($githubCandidate.Minor).$($githubCandidate.Patch)" + + if ([string]::IsNullOrWhiteSpace($PSGalleryVersion.Prerelease) -and + $galleryVersionString -eq $githubCandidateString) { + Write-Host ( + "PowerShell Gallery contains [$galleryVersionString], the next stable version after " + + "GitHub [$GitHubVersion]. Resuming the Gallery-only publication." + ) + $resolvedVersion = $githubCandidate + } + } + + $resolvedVersion + } +} + function Write-ActionOutput { <# .SYNOPSIS diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index 0a0d023f..9a3ac8c1 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -31,15 +31,15 @@ $decision = if ($null -eq $pullRequest) { $releases = @(Get-GitHubRelease) $ghVersion = Get-LatestGitHubVersion -Releases $releases $psGalleryVersion = Get-LatestPSGalleryVersion -ModuleName $actionInput.Name -$latestVersion = Get-LatestPublishedVersion -GitHubVersion $ghVersion -PSGalleryVersion $psGalleryVersion $params = @{ - LatestVersion = $latestVersion + GitHubVersion = $ghVersion + PSGalleryVersion = $psGalleryVersion Decision = $decision Configuration = $config ModuleName = $actionInput.Name Releases = $releases } -$newVersion = Get-NextModuleVersion @params +$newVersion = Get-ResolvedModuleVersion @params Write-ActionOutput -Decision $decision -NewVersion $newVersion 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 72e66aad..4a16a2ea 100644 --- a/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/tests/Resolve-PSModuleVersion.Helpers.Tests.ps1 @@ -137,6 +137,40 @@ Describe 'Resolve-PSModuleVersion' { $releases[0].tagName | Should -Be 'v1.2.3' } } + + Describe 'Get-ResolvedModuleVersion' { + Context 'Get-ResolvedModuleVersion - Gallery-only stable publication' { + It 'Get-ResolvedModuleVersion - reuses the Gallery version that is the next GitHub patch release' { + $params = @{ + GitHubVersion = New-PSSemVer -Version '1.2.3' + PSGalleryVersion = New-PSSemVer -Version '1.2.4' + Decision = Get-TestDecision -Bump 'Patch' + Configuration = Get-TestConfiguration + ModuleName = 'MyModule' + Releases = @() + } + + $result = Get-ResolvedModuleVersion @params + + $result.ToString() | Should -Be 'v1.2.4' + } + + It 'Get-ResolvedModuleVersion - preserves the normal Gallery baseline when versions do not identify a retry' { + $params = @{ + GitHubVersion = New-PSSemVer -Version '1.2.3' + PSGalleryVersion = New-PSSemVer -Version '1.2.5' + Decision = Get-TestDecision -Bump 'Patch' + Configuration = Get-TestConfiguration + ModuleName = 'MyModule' + Releases = @() + } + + $result = Get-ResolvedModuleVersion @params + + $result.ToString() | Should -Be 'v1.2.6' + } + } + } } Describe 'Get-LatestGitHubVersion' { From 70fcb5a7be328d3b24f6d0c2a5770a0b2842481a Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:16:59 +0200 Subject: [PATCH 13/14] Harden stable push routing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Get-PSModuleSettings.Helpers.psm1 | 21 ++++++++++++++ .../actions/Get-PSModuleSettings/src/main.ps1 | 10 +++---- .../Get-PSModuleSettings.Helpers.Tests.ps1 | 29 +++++++++++++++++++ docs/content/reference/scenario-matrix.md | 3 +- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 index 7a588580..ef5aef29 100644 --- a/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 +++ b/.github/actions/Get-PSModuleSettings/src/Get-PSModuleSettings.Helpers.psm1 @@ -75,6 +75,7 @@ function Resolve-WorkflowEventRouting { $HasImportantChanges ) ShouldCleanupEvent = $isClosedPR + ShouldRunCleanup = $isClosedPR -or $shouldRelease } } @@ -128,3 +129,23 @@ function Get-FilesFromGitTree { Where-Object { $_.type -eq 'blob' } | Select-Object -ExpandProperty path } + +function Get-FilesFromGitHubComparison { + <# + .SYNOPSIS + Returns files from a complete GitHub compare response. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [PSCustomObject] $Comparison + ) + + $files = @($Comparison.files | Where-Object { $null -ne $_ }) + if ($files.Count -ge 300) { + throw 'Cannot determine changed files because the GitHub compare response reached its 300-file limit.' + } + + $files | Select-Object -ExpandProperty filename +} diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index d29fbb7f..7eddffea 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -325,7 +325,7 @@ LogGroup 'Calculate Job Run Conditions:' { # Check if important files have changed in the PR # Important files are determined by the configured ImportantFilePatterns setting $hasImportantChanges = $false - if ($pullRequestContext.Number) { + if ($eventName -eq 'pull_request' -and $pullRequestContext.Number) { LogGroup 'Check for Important File Changes' { $owner = $env:GITHUB_REPOSITORY_OWNER $repo = $env:GITHUB_REPOSITORY_NAME @@ -416,10 +416,8 @@ If you believe this is incorrect, please verify that your changes are in the cor $changedFiles = Get-FilesFromGitTree -Tree $tree } else { Write-Host "Fetching changed files between [$beforeCommitSha] and [$commitSha]..." - $changedFiles = Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/compare/$beforeCommitSha...$commitSha" -Method GET | - Select-Object -ExpandProperty Response | - Select-Object -ExpandProperty files | - Select-Object -ExpandProperty filename + $comparison = (Invoke-GitHubAPI -ApiEndpoint "/repos/$owner/$repo/compare/$beforeCommitSha...$commitSha" -Method GET).Response + $changedFiles = Get-FilesFromGitHubComparison -Comparison $comparison } Write-Host "Changed files ($($changedFiles.Count)):" @@ -633,7 +631,7 @@ $settings.Test.Module | Add-Member -MemberType NoteProperty -Name Suites -Value # Calculate job-specific conditions and add to settings LogGroup 'Calculate Job Run Conditions:' { - $shouldAutoCleanup = $routing.ShouldCleanupEvent -and ($settings.Publish.Module.AutoCleanup -eq $true) + $shouldAutoCleanup = $routing.ShouldRunCleanup -and ($settings.Publish.Module.AutoCleanup -eq $true) # Update Publish.Module with computed release values $settings.Publish.Module | Add-Member -MemberType NoteProperty -Name ReleaseType -Value $releaseType -Force 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 c6553184..f7d1fa8e 100644 --- a/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 +++ b/.github/actions/Get-PSModuleSettings/tests/Get-PSModuleSettings.Helpers.Tests.ps1 @@ -12,6 +12,7 @@ Describe 'Resolve-WorkflowEventRouting' { $result.ReleaseType | Should -Be 'Release' $result.ShouldRunBuildTest | Should -BeTrue $result.ShouldCleanupEvent | Should -BeFalse + $result.ShouldRunCleanup | Should -BeTrue } It 'routes a direct push to the default branch to a stable release' { @@ -62,6 +63,7 @@ Describe 'Resolve-WorkflowEventRouting' { $result.ReleaseType | Should -Be 'None' $result.ShouldRunBuildTest | Should -BeFalse $result.ShouldCleanupEvent | Should -BeTrue + $result.ShouldRunCleanup | Should -BeTrue } It 'does not run a label event for a closed PR' { @@ -76,6 +78,7 @@ Describe 'Resolve-WorkflowEventRouting' { $result.IsOpenOrUpdatedPR | Should -BeFalse $result.ShouldRunBuildTest | Should -BeFalse $result.ShouldCleanupEvent | Should -BeFalse + $result.ShouldRunCleanup | Should -BeFalse } } @@ -148,3 +151,29 @@ Describe 'Get-FilesFromGitTree' { { Get-FilesFromGitTree -Tree $tree } | Should -Throw '*tree response was truncated*' } } + +Describe 'Get-FilesFromGitHubComparison' { + It 'returns filenames from a compare response below the file limit' { + $comparison = [pscustomobject]@{ + files = @( + [pscustomobject]@{ filename = 'src/Module.psm1' } + [pscustomobject]@{ filename = 'README.md' } + ) + } + + $result = Get-FilesFromGitHubComparison -Comparison $comparison + + $result | Should -Be @('src/Module.psm1', 'README.md') + } + + It 'rejects a compare response that reaches the file limit' { + $comparison = [pscustomobject]@{ + files = @(1..300 | ForEach-Object { + [pscustomobject]@{ filename = "src/File$_.ps1" } + }) + } + + { Get-FilesFromGitHubComparison -Comparison $comparison } | + Should -Throw '*compare response reached its 300-file limit*' + } +} diff --git a/docs/content/reference/scenario-matrix.md b/docs/content/reference/scenario-matrix.md index 8a5a3d1b..d9c5490f 100644 --- a/docs/content/reference/scenario-matrix.md +++ b/docs/content/reference/scenario-matrix.md @@ -24,7 +24,7 @@ execution; other pages link here rather than repeating it. | **Get-TestResults** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | | **Get-CodeCoverage** | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes | | **Publish-Site** | ❌ No | ✅ Yes* | ❌ No | ✅ Yes* | -| **Publish-Module** | ✅ Prerelease† | ✅ Stable† | ✅ Cleanup‡ | ✅ Stable† | +| **Publish-Module** | ✅ Prerelease† | ✅ Stable†§ | ✅ Cleanup‡ | ✅ Stable†§ | - \* 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 @@ -33,6 +33,7 @@ execution; other pages link here rather than repeating it. release with commit-based notes. - ‡ Cleans up prerelease versions and tags for the closed pull request when `Publish.Module.AutoCleanup` is enabled; it does not publish a stable release. +- § A successful stable release also retries prerelease cleanup when `Publish.Module.AutoCleanup` is enabled. A job that is enabled by this matrix can still be skipped by a setting (for example `Test.Skip`) or because an open PR or default-branch push changed no From 23d90064df06564b6f16d324ee898993d72d1e97 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 15 Aug 2026 12:21:30 +0200 Subject: [PATCH 14/14] Format release routing scripts Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Get-PSModuleSettings/src/main.ps1 | 4 +++- .github/actions/Resolve-PSModuleVersion/src/main.ps1 | 10 +++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/actions/Get-PSModuleSettings/src/main.ps1 b/.github/actions/Get-PSModuleSettings/src/main.ps1 index 7eddffea..da1ad72b 100644 --- a/.github/actions/Get-PSModuleSettings/src/main.ps1 +++ b/.github/actions/Get-PSModuleSettings/src/main.ps1 @@ -268,9 +268,11 @@ LogGroup 'Calculate Job Run Conditions:' { -not [string]::IsNullOrWhiteSpace($pullRequest.merged_at) } $pullRequestIsClosed = $null -ne $pullRequest -and $pullRequest.State -eq 'closed' - $isOpenOrUpdatedPR = $eventName -eq 'pull_request' -and + $isOpenOrUpdatedPR = ( + $eventName -eq 'pull_request' -and -not $pullRequestIsClosed -and $pullRequestAction -in @('opened', 'reopened', 'synchronize', 'labeled', 'unlabeled') + ) $targetBranch = if ($pullRequest) { $pullRequest.Base.Ref } elseif ($isPush) { $pushBranch } else { $workflowRef } $isTargetDefaultBranch = $targetBranch -eq $defaultBranch $pullRequestContext = if ($pullRequest) { diff --git a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 index 9a3ac8c1..ad3b4a71 100644 --- a/.github/actions/Resolve-PSModuleVersion/src/main.ps1 +++ b/.github/actions/Resolve-PSModuleVersion/src/main.ps1 @@ -33,12 +33,12 @@ $ghVersion = Get-LatestGitHubVersion -Releases $releases $psGalleryVersion = Get-LatestPSGalleryVersion -ModuleName $actionInput.Name $params = @{ - GitHubVersion = $ghVersion + GitHubVersion = $ghVersion PSGalleryVersion = $psGalleryVersion - Decision = $decision - Configuration = $config - ModuleName = $actionInput.Name - Releases = $releases + Decision = $decision + Configuration = $config + ModuleName = $actionInput.Name + Releases = $releases } $newVersion = Get-ResolvedModuleVersion @params