From ee24a8dd49a5b143cc154b1207a7fb8155df41d3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 22:57:14 +0200 Subject: [PATCH 01/17] Add canonical release decision resolver --- src/Release-GHRepository.Helpers.psm1 | 153 +++++++++++++++ tests/Release-GHRepository.Helpers.Tests.ps1 | 184 +++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/Release-GHRepository.Helpers.psm1 create mode 100644 tests/Release-GHRepository.Helpers.Tests.ps1 diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 new file mode 100644 index 0000000..08f8f7a --- /dev/null +++ b/src/Release-GHRepository.Helpers.psm1 @@ -0,0 +1,153 @@ +$ErrorActionPreference = 'Stop' + +function Get-ReleaseLabelDefinition { + <# + .SYNOPSIS + Return the canonical release labels owned by Release-GHRepository. + + .DESCRIPTION + Return the canonical label names and repository metadata used when the action + provisions its owned release controls. + + .EXAMPLE + Get-ReleaseLabelDefinition + + Return all canonical release-label definitions. + + .INPUTS + None + + You can't pipe objects to Get-ReleaseLabelDefinition. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + A canonical release-label definition. + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param() + + [PSCustomObject]@{ + Name = 'release:patch' + Color = '5ac3a5' + Description = 'Publish a patch release.' + } + [PSCustomObject]@{ + Name = 'release:minor' + Color = '616f09' + Description = 'Publish a minor release.' + } + [PSCustomObject]@{ + Name = 'release:major' + Color = 'b60205' + Description = 'Publish a major release.' + } + [PSCustomObject]@{ + Name = 'release:pre-release' + Color = '8d7bdf' + Description = 'Publish a prerelease from this open pull request.' + } + [PSCustomObject]@{ + Name = 'release:skip' + Color = 'ededed' + Description = 'Validate this change without publishing a release.' + } +} + +function Resolve-ReleaseDecision { + <# + .SYNOPSIS + Resolve a release decision from canonical pull-request labels. + + .DESCRIPTION + Evaluate only the five labels owned by Release-GHRepository. Return one + explicit bump or skip decision and reject missing or conflicting owned-label + combinations without applying a default. + + .EXAMPLE + Resolve-ReleaseDecision -Labels @('release:minor') + + Return a Minor stable-release decision. + + .EXAMPLE + Resolve-ReleaseDecision -Labels @('release:patch', 'release:pre-release') + + Return a Patch prerelease decision. + + .INPUTS + None + + You can't pipe objects to Resolve-ReleaseDecision. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The validated release decision. + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + # All labels currently applied to the pull request. + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowNull()] + [string[]] $Labels + ) + + $bumpLabelTypes = [ordered]@{ + 'release:patch' = 'Patch' + 'release:minor' = 'Minor' + 'release:major' = 'Major' + } + $ownedLabelNames = @($bumpLabelTypes.Keys) + @('release:pre-release', 'release:skip') + $ownedLabels = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::Ordinal + ) + + foreach ($label in $Labels) { + if ($ownedLabelNames -ccontains $label) { + $null = $ownedLabels.Add($label) + } + } + + $hasSkip = $ownedLabels.Contains('release:skip') + $hasPrerelease = $ownedLabels.Contains('release:pre-release') + $bumpLabels = @($bumpLabelTypes.Keys | Where-Object { $ownedLabels.Contains($_) }) + + if ($hasSkip) { + if ($ownedLabels.Count -ne 1) { + throw 'Invalid release labels: release:skip must not be combined with another release label.' + } + + [PSCustomObject]@{ + Bump = 'None' + Prerelease = $false + Skip = $true + } + return + } + + if ($bumpLabels.Count -eq 0) { + if ($hasPrerelease) { + throw 'Invalid release labels: release:pre-release requires exactly one release bump label.' + } + + throw ( + 'Release decision is missing. Apply exactly one of release:patch, release:minor, ' + + 'release:major, or release:skip.' + ) + } + + if ($bumpLabels.Count -gt 1) { + throw "Conflicting release bump labels: [$($bumpLabels -join ', ')]. Apply exactly one bump label." + } + + [PSCustomObject]@{ + Bump = $bumpLabelTypes[$bumpLabels[0]] + Prerelease = $hasPrerelease + Skip = $false + } +} + +Export-ModuleMember -Function Get-ReleaseLabelDefinition, Resolve-ReleaseDecision diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 new file mode 100644 index 0000000..1f6d92e --- /dev/null +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -0,0 +1,184 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' +)] +[CmdletBinding()] +param() + +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '../src/Release-GHRepository.Helpers.psm1' + Import-Module -Name $modulePath -Force +} + +Describe 'Get-ReleaseLabelDefinition' { + BeforeAll { + $definitions = @(Get-ReleaseLabelDefinition) + } + + It 'returns exactly the five canonical labels' { + $definitions.Name | Should -Be @( + 'release:patch' + 'release:minor' + 'release:major' + 'release:pre-release' + 'release:skip' + ) + } + + It 'returns a unique name for every definition' { + @($definitions.Name | Sort-Object -Unique).Count | Should -Be $definitions.Count + } + + It 'returns valid repository metadata for ' -ForEach @( + @{ Name = 'release:patch' } + @{ Name = 'release:minor' } + @{ Name = 'release:major' } + @{ Name = 'release:pre-release' } + @{ Name = 'release:skip' } + ) { + $definition = $definitions | Where-Object Name -CEQ $Name + + $definition.Color | Should -Match '^[0-9a-f]{6}$' + $definition.Description | Should -Not -BeNullOrEmpty + $definition.Description.Length | Should -BeLessOrEqual 100 + } +} + +Describe 'Resolve-ReleaseDecision' { + It 'resolves ' -ForEach @( + @{ + Name = 'a patch release' + Labels = @('release:patch') + Bump = 'Patch' + Prerelease = $false + Skip = $false + } + @{ + Name = 'a minor release' + Labels = @('release:minor') + Bump = 'Minor' + Prerelease = $false + Skip = $false + } + @{ + Name = 'a major release' + Labels = @('release:major') + Bump = 'Major' + Prerelease = $false + Skip = $false + } + @{ + Name = 'a patch prerelease' + Labels = @('release:patch', 'release:pre-release') + Bump = 'Patch' + Prerelease = $true + Skip = $false + } + @{ + Name = 'a minor prerelease' + Labels = @('release:minor', 'release:pre-release') + Bump = 'Minor' + Prerelease = $true + Skip = $false + } + @{ + Name = 'a major prerelease' + Labels = @('release:major', 'release:pre-release') + Bump = 'Major' + Prerelease = $true + Skip = $false + } + @{ + Name = 'a skipped release' + Labels = @('release:skip') + Bump = 'None' + Prerelease = $false + Skip = $true + } + @{ + Name = 'a bump alongside unrelated labels' + Labels = @('dependencies', 'release:patch', 'release:unknown') + Bump = 'Patch' + Prerelease = $false + Skip = $false + } + @{ + Name = 'a skip alongside unrelated labels' + Labels = @('documentation', 'release:skip', 'release:unknown') + Bump = 'None' + Prerelease = $false + Skip = $true + } + ) { + $result = Resolve-ReleaseDecision -Labels $Labels + + $result.Bump | Should -BeExactly $Bump + $result.Prerelease | Should -Be $Prerelease + $result.Skip | Should -Be $Skip + } + + It 'rejects ' -ForEach @( + @{ + Name = 'an empty label set' + Labels = @() + Message = '*Release decision is missing*' + } + @{ + Name = 'a null label set' + Labels = $null + Message = '*Release decision is missing*' + } + @{ + Name = 'legacy bare labels' + Labels = @('Major', 'Minor', 'Patch', 'Prerelease', 'NoRelease') + Message = '*Release decision is missing*' + } + @{ + Name = 'lowercase bare labels' + Labels = @('major', 'minor', 'patch', 'prerelease') + Message = '*Release decision is missing*' + } + @{ + Name = 'noncanonical casing' + Labels = @('Release:Patch') + Message = '*Release decision is missing*' + } + @{ + Name = 'an unknown release label' + Labels = @('release:unknown') + Message = '*Release decision is missing*' + } + @{ + Name = 'prerelease without a bump' + Labels = @('release:pre-release') + Message = '*release:pre-release requires exactly one release bump label*' + } + @{ + Name = 'patch and minor bumps' + Labels = @('release:patch', 'release:minor') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'minor and major bumps' + Labels = @('release:minor', 'release:major') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'all bump labels' + Labels = @('release:patch', 'release:minor', 'release:major') + Message = '*Conflicting release bump labels*' + } + @{ + Name = 'skip and patch' + Labels = @('release:skip', 'release:patch') + Message = '*release:skip must not be combined*' + } + @{ + Name = 'skip and prerelease' + Labels = @('release:skip', 'release:pre-release') + Message = '*release:skip must not be combined*' + } + ) { + { Resolve-ReleaseDecision -Labels $Labels } | Should -Throw $Message + } +} From 8071946bc9ddabd6cf0955f36020c846d2457697 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 22:58:43 +0200 Subject: [PATCH 02/17] Enforce namespaced release decisions --- action.yml | 27 +--------------------- src/main.ps1 | 65 ++++++++++++++++++++++++++++++++-------------------- 2 files changed, 41 insertions(+), 51 deletions(-) diff --git a/action.yml b/action.yml index bd28559..0e03db5 100644 --- a/action.yml +++ b/action.yml @@ -10,10 +10,6 @@ inputs: description: Control whether to automatically delete the prerelease tags after the stable release is created. required: false default: 'true' - AutoPatching: - description: Control whether to automatically handle patches. If disabled, the action will only create a patch release if the pull request has a 'patch' label. - required: false - default: 'true' ConfigurationFile: description: The path to the configuration file. Settings in the configuration file take precedence over the action inputs. required: false @@ -30,26 +26,10 @@ inputs: description: If specified, uses a date based prerelease scheme. The format should be a valid .NET format string like 'yyyyMMddHHmm'. required: false default: '' - IgnoreLabels: - description: A comma separated list of labels that do not trigger a release. - required: false - default: NoRelease IncrementalPrerelease: description: Control whether to automatically increment the prerelease number. If disabled, the action will ensure only one prerelease exists for a given branch. required: false default: 'true' - MajorLabels: - description: A comma separated list of labels that trigger a major release. - required: false - default: major, breaking - MinorLabels: - description: A comma separated list of labels that trigger a minor release. - required: false - default: minor, feature - PatchLabels: - description: A comma separated list of labels that trigger a patch release. - required: false - default: patch, fix UsePRTitleAsReleaseName: description: When enabled, uses the pull request title as the name for the GitHub release. required: false @@ -82,7 +62,7 @@ inputs: description: Specifies the version of the GitHub module to be installed. Accepts an exact version or a NuGet version range (for example '[1.2.0, 2.0.0)'). required: false Prerelease: - description: Allow prerelease versions if available. + description: Allow prerelease versions of the GitHub module dependency. This does not control repository releases; use the release:pre-release label. required: false default: 'false' WorkingDirectory: @@ -97,16 +77,11 @@ runs: uses: PSModule/GitHub-Script@8083ec1f733f00357ee4d0db0c6056686e483bc0 # v1.9.0 env: PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup: ${{ inputs.AutoCleanup }} - PSMODULE_AUTO_RELEASE_INPUT_AutoPatching: ${{ inputs.AutoPatching }} PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile: ${{ inputs.ConfigurationFile }} PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag: ${{ inputs.CreateMajorTag }} PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag: ${{ inputs.CreateMinorTag }} PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat: ${{ inputs.DatePrereleaseFormat }} - PSMODULE_AUTO_RELEASE_INPUT_IgnoreLabels: ${{ inputs.IgnoreLabels }} PSMODULE_AUTO_RELEASE_INPUT_IncrementalPrerelease: ${{ inputs.IncrementalPrerelease }} - PSMODULE_AUTO_RELEASE_INPUT_MajorLabels: ${{ inputs.MajorLabels }} - PSMODULE_AUTO_RELEASE_INPUT_MinorLabels: ${{ inputs.MinorLabels }} - PSMODULE_AUTO_RELEASE_INPUT_PatchLabels: ${{ inputs.PatchLabels }} PSMODULE_AUTO_RELEASE_INPUT_UsePRBodyAsReleaseNotes: ${{ inputs.UsePRBodyAsReleaseNotes }} PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} diff --git a/src/main.ps1 b/src/main.ps1 index 089a75f..121db3a 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -8,6 +8,11 @@ [CmdletBinding()] param() +$ErrorActionPreference = 'Stop' + +$helperModulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Release-GHRepository.Helpers.psm1' +Import-Module -Name $helperModulePath -Force + LogGroup 'Loading libraries' { 'powershell-yaml', 'PSSemVer' | ForEach-Object { $name = $_ @@ -40,7 +45,6 @@ LogGroup 'Set configuration' { } $autoCleanup = ![string]::IsNullOrEmpty($configuration.AutoCleanup) ? $configuration.AutoCleanup -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup -eq 'true' - $autoPatching = ![string]::IsNullOrEmpty($configuration.AutoPatching) ? $configuration.AutoPatching -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_AutoPatching -eq 'true' $createMajorTag = ![string]::IsNullOrEmpty($configuration.CreateMajorTag) ? $configuration.CreateMajorTag -EQ 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag -EQ 'true' $createMinorTag = ![string]::IsNullOrEmpty($configuration.CreateMinorTag) ? $configuration.CreateMinorTag -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag -eq 'true' $datePrereleaseFormat = ![string]::IsNullOrEmpty($configuration.DatePrereleaseFormat) ? $configuration.DatePrereleaseFormat : $env:PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat @@ -51,14 +55,8 @@ LogGroup 'Set configuration' { $versionPrefix = ![string]::IsNullOrEmpty($configuration.VersionPrefix) ? $configuration.VersionPrefix : $env:PSMODULE_AUTO_RELEASE_INPUT_VersionPrefix $whatIf = ![string]::IsNullOrEmpty($configuration.WhatIf) ? $configuration.WhatIf -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_WhatIf -eq 'true' - $ignoreLabels = (![string]::IsNullOrEmpty($configuration.IgnoreLabels) ? $configuration.IgnoreLabels : $env:PSMODULE_AUTO_RELEASE_INPUT_IgnoreLabels) -split ',' | ForEach-Object { $_.Trim() } - $majorLabels = (![string]::IsNullOrEmpty($configuration.MajorLabels) ? $configuration.MajorLabels : $env:PSMODULE_AUTO_RELEASE_INPUT_MajorLabels) -split ',' | ForEach-Object { $_.Trim() } - $minorLabels = (![string]::IsNullOrEmpty($configuration.MinorLabels) ? $configuration.MinorLabels : $env:PSMODULE_AUTO_RELEASE_INPUT_MinorLabels) -split ',' | ForEach-Object { $_.Trim() } - $patchLabels = (![string]::IsNullOrEmpty($configuration.PatchLabels) ? $configuration.PatchLabels : $env:PSMODULE_AUTO_RELEASE_INPUT_PatchLabels) -split ',' | ForEach-Object { $_.Trim() } - Write-Output '-------------------------------------------------' Write-Output "Auto cleanup enabled: [$autoCleanup]" - Write-Output "Auto patching enabled: [$autoPatching]" Write-Output "Create major tag enabled: [$createMajorTag]" Write-Output "Create minor tag enabled: [$createMinorTag]" Write-Output "Date-based prerelease format: [$datePrereleaseFormat]" @@ -68,14 +66,34 @@ LogGroup 'Set configuration' { Write-Output "Use PR title as notes heading: [$usePRTitleAsNotesHeading]" Write-Output "Version prefix: [$versionPrefix]" Write-Output "What if mode: [$whatIf]" - Write-Output '' - Write-Output "Ignore labels: [$($ignoreLabels -join ', ')]" - Write-Output "Major labels: [$($majorLabels -join ', ')]" - Write-Output "Minor labels: [$($minorLabels -join ', ')]" - Write-Output "Patch labels: [$($patchLabels -join ', ')]" Write-Output '-------------------------------------------------' } +LogGroup 'Provision release labels' { + if ([string]::IsNullOrWhiteSpace($env:GITHUB_REPOSITORY)) { + throw 'GITHUB_REPOSITORY is required to provision release labels.' + } + + foreach ($definition in Get-ReleaseLabelDefinition) { + $arguments = @( + 'label' + 'create' + $definition.Name + '--repo' + $env:GITHUB_REPOSITORY + '--color' + $definition.Color + '--description' + $definition.Description + '--force' + ) + gh @arguments + if ($LASTEXITCODE -ne 0) { + throw "Failed to provision the canonical label [$($definition.Name)]." + } + } +} + LogGroup 'Event information - JSON' { $githubEventJson = Get-Content $env:GITHUB_EVENT_PATH $githubEventJson | Format-List | Out-String @@ -125,22 +143,19 @@ LogGroup 'Pull request - Labels' { $labels | Format-List | Out-String } -$createRelease = $isMerged -and $targetIsDefaultBranch +$releaseDecision = Resolve-ReleaseDecision -Labels $labels + +$createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged -$createPrerelease = $labels -Contains 'prerelease' -and -not $createRelease -and -not $closedPullRequest +$createPrerelease = $releaseDecision.Prerelease -and -not $createRelease -and -not $closedPullRequest $prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]' -$ignoreRelease = ($labels | Where-Object { $ignoreLabels -contains $_ }).Count -gt 0 -if ($ignoreRelease) { - Write-Output 'Ignoring release creation.' - return -} - -$majorRelease = ($labels | Where-Object { $majorLabels -contains $_ }).Count -gt 0 -$minorRelease = ($labels | Where-Object { $minorLabels -contains $_ }).Count -gt 0 -and -not $majorRelease -$patchRelease = (($labels | Where-Object { $patchLabels -contains $_ }).Count -gt 0 -or $autoPatching) -and -not $majorRelease -and -not $minorRelease +$majorRelease = $releaseDecision.Bump -eq 'Major' +$minorRelease = $releaseDecision.Bump -eq 'Minor' +$patchRelease = $releaseDecision.Bump -eq 'Patch' Write-Output '-------------------------------------------------' +Write-Output "Skip release: [$($releaseDecision.Skip)]" Write-Output "Create a release: [$createRelease]" Write-Output "Create a prerelease: [$createPrerelease]" Write-Output "Create a major release: [$majorRelease]" @@ -175,7 +190,7 @@ Write-Output '-------------------------------------------------' Write-Output "Latest version: [$latestVersion]" Write-Output '-------------------------------------------------' -if ($createPrerelease -or $createRelease -or $whatIf) { +if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $whatIf)) { LogGroup 'Calculate new version' { $latestVersion = New-PSSemVer -Version $latestVersion $newVersion = New-PSSemVer -Version $latestVersion @@ -369,7 +384,7 @@ LogGroup 'List prereleases using the same name' { $prereleasesToCleanup | Select-Object -Property name, publishedAt, isPrerelease, isLatest | Format-Table | Out-String } -if ((($closedPullRequest -or $createRelease) -and $autoCleanup) -or $whatIf) { +if (($prIsClosed -and $autoCleanup) -or $whatIf) { LogGroup "Cleanup prereleases for [$prereleaseName]" { foreach ($rel in $prereleasesToCleanup) { $relTagName = $rel.tagName From d7b53597295f5c7ca2582063beb0eab47ea96caa Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 22:59:42 +0200 Subject: [PATCH 03/17] Run release checks on label transitions --- .github/workflows/Action-Test.yml | 31 ++++++++++++++++++++++++++++--- .github/workflows/Release.yml | 5 ++--- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index b2a7d72..fa38605 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -5,6 +5,12 @@ run-name: "Action-Test - [${{ github.event.pull_request.title }} #${{ github.eve on: workflow_dispatch: pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled schedule: - cron: '0 0 * * *' @@ -12,14 +18,33 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - contents: read - pull-requests: read +permissions: {} jobs: + UnitTest: + name: Pester 6.1 + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Test release decisions + uses: PSModule/Invoke-Pester@4ff33199141fdf22568990b6107fe3148ae93a1c # v5.1.0 + with: + Path: tests + Version: 6.1.0 + ActionTestDefault: name: Action-Test - [Default] runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: read steps: - name: Checkout repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index fd81eb0..f79d9fe 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -12,9 +12,7 @@ on: - reopened - synchronize - labeled - paths: - - 'action.yml' - - 'src/**' + - unlabeled concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -22,6 +20,7 @@ concurrency: permissions: contents: write # Required to create releases + issues: write # Required to provision release labels pull-requests: write # Required to create comments on the PRs jobs: From c17172abdcb96cd345e3e0f0934f40918338b128 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:00:57 +0200 Subject: [PATCH 04/17] Document the v3 release label contract --- README.md | 156 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 80 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 9bcd513..e5cba12 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,41 @@ # Release-GHRepository -Automatically creates releases based on pull requests and labels. +Create GitHub releases from explicit, owned pull-request labels. +## Release decision -## Specifications and practices +Release-GHRepository owns and provisions five labels: -Release-GHRepository follows: +| Label | Instruction | Valid combination | +| --- | --- | --- | +| `release:patch` | Increment the patch version. | Exactly one bump label. | +| `release:minor` | Increment the minor version. | Exactly one bump label. | +| `release:major` | Increment the major version. | Exactly one bump label. | +| `release:pre-release` | Publish from an open pull request as a prerelease. | With exactly one bump label. | +| `release:skip` | Validate without publishing a release. | Without another owned release label. | -- [SemVer 2.0.0 specifications](https://semver.org) -- [GitHub Flow specifications](https://docs.github.com/en/get-started/using-github/github-flow) -- [Continuous Delivery practices](https://en.wikipedia.org/wiki/Continuous_delivery) +Exactly one bump label or `release:skip` is required. There is no default release decision. -## How it works - -The workflow will trigger on pull requests to the main branch. - -The following labels will inform the action what kind of release to create: -- For a major release, and increasing the first number in the version use: - - `major` - - `breaking` -- For a minor release, and increasing the second number in the version. - - `minor` - - `feature` -- For a patch release, and increases the third number in the version. - - `patch` - - `fix` +The action rejects: -When a pull request is closed, the action will create a release based on the labels and clean up any previous prereleases that were created. +- a missing decision; +- multiple bump labels; +- `release:skip` with another owned release label; +- `release:pre-release` without exactly one bump label. -> [!NOTE] -> The labels can be configured using the `MajorLabels`, `MinorLabels` and `PatchLabels` parameters/settings in the configuration file to trigger -> on other labels. - -This action is built on [GitHub-Script](https://github.com/PSModule/GitHub-Script) which by default uses the `GITHUB_TOKEN`. -## Usage - -The action can be configured using the following settings: - -| Name | Description | Default | Required | -| --- | --- | --- | --- | -| `AutoCleanup` | Control whether to automatically cleanup prereleases. If disabled, the action will not remove any prereleases. | `true` | false | -| `AutoPatching` | Control whether to automatically handle patches. If disabled, the action will only create a patch release if the pull request has a 'patch' label. | `true` | false | -| `ConfigurationFile` | The path to the configuration file. Settings in the configuration file take precedence over the action inputs. | `.github\auto-release.yml` | false | -| `CreateMajorTag` | Control whether to create a tag for major releases. | `true` | false | -| `CreateMinorTag` | Control whether to create a tag for minor releases. | `true` | false | -| `DatePrereleaseFormat` | The format to use for the prerelease number using [.NET DateTime format strings](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings). | `''` | false | -| `IgnoreLabels` | A comma separated list of labels that do not trigger a release. | `NoRelease` | false | -| `IncrementalPrerelease` | Control whether to automatically increment the prerelease number. If disabled, the action will ensure only one prerelease exists for a given branch. | `true` | false | -| `MajorLabels` | A comma separated list of labels that trigger a major release. | `major, breaking` | false | -| `MinorLabels` | A comma separated list of labels that trigger a minor release. | `minor, feature` | false | -| `PatchLabels` | A comma separated list of labels that trigger a patch release. | `patch, fix` | false | -| `UsePRTitleAsReleaseName` | When enabled, uses the pull request title as the name for the GitHub release. | `false` | false | -| `UsePRBodyAsReleaseNotes` | When enabled, uses the pull request body as the release notes for the GitHub release. | `true` | false | -| `UsePRTitleAsNotesHeading` | When enabled, the release notes will begin with the pull request title as a H1 heading followed by the pull request body. The title will include a reference to the PR number. | `true` | false | -| `VersionPrefix` | The prefix to use for the version number. | `v` | false | -| `WhatIf` | Control whether to simulate the action. If enabled, the action will not create any releases. Used for testing. | `false` | false | -| `Debug` | Enable debug output. | `'false'` | false | -| `Verbose` | Enable verbose output. | `'false'` | false | -| `Version` | Specifies the version of the GitHub module to install. Accepts an exact version or a NuGet version range (for example `[1.2.0, 2.0.0)`). | | false | -| `Prerelease` | Allow prerelease versions if available. | `'false'` | false | -| `WorkingDirectory` | The working directory where the script runs. | `${{ github.workspace }}` | false | +Labels outside this set do not affect releases. Bare and legacy labels such as `Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`, `major`, `minor`, and `patch` are not release decisions. -### Configuration file +## How it works -The configuration file is a YAML file that can be used to configure the action. -By default, the configuration file is expected at `.github\auto-release.yml`, which can be changed using the `ConfigurationFile` setting. -The action's configuration can be changed by altering the settings in the configuration file. +On every run, the action creates missing canonical labels and reconciles their colors and descriptions. It leaves all other repository labels unchanged. -```yaml -DatePrereleaseFormat: 'yyyyMMddHHmm' -IncrementalPrerelease: false -VersionPrefix: '' -``` +An open pull request with `release:pre-release` and one bump label publishes a prerelease. A pull request merged into the default branch with one bump label publishes the stable release. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. -This example uses the date format for the prerelease, disables the incremental prerelease and removes the version prefix. +The workflow must run for `labeled` and `unlabeled` events so both valid and invalid label transitions are evaluated. Do not use a workflow path filter to bypass the release decision on non-artifact changes; use `release:skip`. -## Example +This action is built on [GitHub-Script](https://github.com/PSModule/GitHub-Script), which uses the workflow token by default. -Add a workflow in your repository using the following example: +## Usage ```yaml name: Release-GHRepository @@ -92,27 +50,73 @@ on: - reopened - synchronize - labeled + - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + +permissions: + contents: write # Required to create releases and tags + issues: write # Required to provision repository labels + pull-requests: write # Required to comment on pull requests jobs: Release-GHRepository: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - name: Checkout Code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - - name: Release-GHRepository - uses: PSModule/Release-GHRepository@v1 + - name: Release repository + uses: PSModule/Release-GHRepository@v3 ``` -## Permissions +The `pull_request_target` workflow checks out the trusted base branch. Do not check out or execute an untrusted pull-request head with this writable token. + +## Inputs + +| Name | Description | Default | Required | +| --- | --- | --- | --- | +| `AutoCleanup` | Delete prereleases after the pull request closes. | `true` | false | +| `ConfigurationFile` | Read settings from this file. File settings take precedence over action inputs. | `.github\auto-release.yml` | false | +| `CreateMajorTag` | Create or update the floating major tag after a stable release. | `true` | false | +| `CreateMinorTag` | Create or update the floating minor tag after a stable release. | `true` | false | +| `DatePrereleaseFormat` | Append a [.NET date and time format](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings) to prerelease versions. | `''` | false | +| `IncrementalPrerelease` | Increment the prerelease number; when false, keep only one prerelease for the branch. | `true` | false | +| `UsePRTitleAsReleaseName` | Use the pull-request title as the GitHub Release name. | `false` | false | +| `UsePRBodyAsReleaseNotes` | Use the pull-request body as release notes. | `true` | false | +| `UsePRTitleAsNotesHeading` | Add the pull-request title and number as the release-notes heading. | `true` | false | +| `VersionPrefix` | Prefix the version number. | `v` | false | +| `WhatIf` | Log release changes without creating or deleting releases or tags. | `false` | false | +| `Debug` | Enable debug output. | `false` | false | +| `Verbose` | Enable verbose output. | `false` | false | +| `Version` | Select the GitHub module dependency by exact version or NuGet version range. | | false | +| `Prerelease` | Allow a prerelease version of the GitHub module dependency. This does not select a repository prerelease. | `false` | false | +| `WorkingDirectory` | Set the directory where the script runs. | `${{ github.workspace }}` | false | + +Use the `release:pre-release` label to select repository prerelease behavior. The similarly named `Prerelease` action input only controls dependency resolution for the GitHub module used internally. + +### Configuration file -If running the action in a restrictive mode, the following permissions needs to be granted to the action: +The default configuration file is `.github\auto-release.yml`. Change its path with `ConfigurationFile`. ```yaml -permissions: - contents: write # Required to create releases - pull-requests: write # Required to create comments on the PRs +DatePrereleaseFormat: 'yyyyMMddHHmm' +IncrementalPrerelease: false +VersionPrefix: '' ``` + +## Migrate from v2 + +`v3` is a breaking release. Existing `v2` references and behavior remain unchanged. + +1. Add `issues: write` to the release job and subscribe the workflow to `unlabeled`. +2. Remove workflow path filters so `release:skip` decisions are validated. +3. Remove `AutoPatching`, `IgnoreLabels`, `MajorLabels`, `MinorLabels`, and `PatchLabels` from action inputs and configuration files. +4. Apply one canonical decision to every open pull request. +5. Update the action reference to `PSModule/Release-GHRepository@v3` after `v3.0.0` is published. +6. Remove legacy release labels after no open pull request uses them. Removing bare `major`, `minor`, and `patch` labels also prevents Dependabot from applying them as dependency-version metadata. + +The first v3 run provisions the canonical labels before validating the pull request. A pull request without a canonical decision fails until a maintainer applies one. From 8e4578ba6a23fb3804ea9c6e9f8be3dcfcf8cfe1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:02:50 +0200 Subject: [PATCH 05/17] Cover the complete release label matrix --- README.md | 8 +++-- tests/Release-GHRepository.Helpers.Tests.ps1 | 38 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5cba12..46f6783 100644 --- a/README.md +++ b/README.md @@ -115,8 +115,10 @@ VersionPrefix: '' 1. Add `issues: write` to the release job and subscribe the workflow to `unlabeled`. 2. Remove workflow path filters so `release:skip` decisions are validated. 3. Remove `AutoPatching`, `IgnoreLabels`, `MajorLabels`, `MinorLabels`, and `PatchLabels` from action inputs and configuration files. -4. Apply one canonical decision to every open pull request. -5. Update the action reference to `PSModule/Release-GHRepository@v3` after `v3.0.0` is published. -6. Remove legacy release labels after no open pull request uses them. Removing bare `major`, `minor`, and `patch` labels also prevents Dependabot from applying them as dependency-version metadata. +4. Provision the five canonical labels before opening the migration pull request. The action reconciles them on every subsequent run. +5. Apply both the existing v2 decision and the equivalent canonical decision to the migration pull request so either workflow version can process it. +6. Update the action reference to `PSModule/Release-GHRepository@v3` after `v3.0.0` is published. +7. Apply one canonical decision to every other open pull request. +8. Remove legacy release labels after no open pull request uses them. Removing bare `major`, `minor`, and `patch` labels also prevents Dependabot from applying them as dependency-version metadata. The first v3 run provisions the canonical labels before validating the pull request. A pull request without a canonical decision fails until a maintainer applies one. diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 1f6d92e..e4f5062 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -181,4 +181,42 @@ Describe 'Resolve-ReleaseDecision' { ) { { Resolve-ReleaseDecision -Labels $Labels } | Should -Throw $Message } + + It 'accepts exactly the seven valid subsets of owned release labels' { + $ownedLabels = @( + 'release:patch' + 'release:minor' + 'release:major' + 'release:pre-release' + 'release:skip' + ) + $validSubsets = @( + 'release:patch' + 'release:minor' + 'release:major' + 'release:patch,release:pre-release' + 'release:minor,release:pre-release' + 'release:major,release:pre-release' + 'release:skip' + ) + + for ($mask = 0; $mask -lt (1 -shl $ownedLabels.Count); $mask++) { + $labels = @( + for ($index = 0; $index -lt $ownedLabels.Count; $index++) { + if (($mask -band (1 -shl $index)) -ne 0) { + $ownedLabels[$index] + } + } + ) + $subset = ($labels | Sort-Object) -join ',' + + if ($validSubsets -ccontains $subset) { + { Resolve-ReleaseDecision -Labels $labels } | + Should -Not -Throw -Because "[$subset] is a valid release decision" + } else { + { Resolve-ReleaseDecision -Labels $labels } | + Should -Throw -Because "[$subset] is not a valid release decision" + } + } + } } From 0a8c09aa8116d9d164d9a63abc6882138d7d7213 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:08:53 +0200 Subject: [PATCH 06/17] Keep release checks safe for dry runs and forks --- .github/workflows/Action-Test.yml | 1 - .github/workflows/Release.yml | 2 +- README.md | 4 ++-- src/main.ps1 | 10 +++++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index fa38605..89aeb86 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -43,7 +43,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - issues: write pull-requests: read steps: - name: Checkout repo diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index f79d9fe..2a04c2b 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -3,7 +3,7 @@ name: Release run-name: "Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" on: - pull_request: + pull_request_target: branches: - main types: diff --git a/README.md b/README.md index 46f6783..89b1bae 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Labels outside this set do not affect releases. Bare and legacy labels such as ` ## How it works -On every run, the action creates missing canonical labels and reconciles their colors and descriptions. It leaves all other repository labels unchanged. +On every non-WhatIf run, the action creates missing canonical labels and reconciles their colors and descriptions. It leaves all other repository labels unchanged. An open pull request with `release:pre-release` and one bump label publishes a prerelease. A pull request merged into the default branch with one bump label publishes the stable release. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. @@ -89,7 +89,7 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch | `UsePRBodyAsReleaseNotes` | Use the pull-request body as release notes. | `true` | false | | `UsePRTitleAsNotesHeading` | Add the pull-request title and number as the release-notes heading. | `true` | false | | `VersionPrefix` | Prefix the version number. | `v` | false | -| `WhatIf` | Log release changes without creating or deleting releases or tags. | `false` | false | +| `WhatIf` | Log release and label changes without mutating repository state. | `false` | false | | `Debug` | Enable debug output. | `false` | false | | `Verbose` | Enable verbose output. | `false` | false | | `Version` | Select the GitHub module dependency by exact version or NuGet version range. | | false | diff --git a/src/main.ps1 b/src/main.ps1 index 121db3a..6af4b03 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -87,9 +87,13 @@ LogGroup 'Provision release labels' { $definition.Description '--force' ) - gh @arguments - if ($LASTEXITCODE -ne 0) { - throw "Failed to provision the canonical label [$($definition.Name)]." + if ($whatIf) { + Write-Output "WhatIf: gh $($arguments -join ' ')" + } else { + gh @arguments + if ($LASTEXITCODE -ne 0) { + throw "Failed to provision the canonical label [$($definition.Name)]." + } } } } From a2ec1962e096138370ceb2b2da8d065d2df3c07c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:14:30 +0200 Subject: [PATCH 07/17] Restrict prereleases to open pull requests --- src/Release-GHRepository.Helpers.psm1 | 48 +++++++++++++++++++- src/main.ps1 | 2 +- tests/Release-GHRepository.Helpers.Tests.ps1 | 40 ++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 08f8f7a..3154e51 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -150,4 +150,50 @@ function Resolve-ReleaseDecision { } } -Export-ModuleMember -Function Get-ReleaseLabelDefinition, Resolve-ReleaseDecision +function Test-PrereleaseCreation { + <# + .SYNOPSIS + Test whether the current pull-request event may create a prerelease. + + .DESCRIPTION + Return true only when a validated release decision requests a prerelease + and the pull request remains open. + + .EXAMPLE + $decision = Resolve-ReleaseDecision -Labels @('release:patch', 'release:pre-release') + Test-PrereleaseCreation -ReleaseDecision $decision + + Return true for an open pull request carrying a valid prerelease decision. + + .EXAMPLE + $decision = Resolve-ReleaseDecision -Labels @('release:patch', 'release:pre-release') + Test-PrereleaseCreation -ReleaseDecision $decision -PullRequestClosed + + Return false after the pull request closes. + + .INPUTS + None + + You can't pipe objects to Test-PrereleaseCreation. + + .OUTPUTS + System.Boolean + + Whether the event may create a prerelease. + #> + [OutputType([bool])] + [CmdletBinding()] + param( + # The validated canonical release decision. + [Parameter(Mandatory)] + [PSCustomObject] $ReleaseDecision, + + # Indicate that the pull request is closed, whether merged or abandoned. + [Parameter()] + [switch] $PullRequestClosed + ) + + $ReleaseDecision.Prerelease -and -not $ReleaseDecision.Skip -and -not $PullRequestClosed +} + +Export-ModuleMember -Function Get-ReleaseLabelDefinition, Resolve-ReleaseDecision, Test-PrereleaseCreation diff --git a/src/main.ps1 b/src/main.ps1 index 6af4b03..511af95 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -151,7 +151,7 @@ $releaseDecision = Resolve-ReleaseDecision -Labels $labels $createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged -$createPrerelease = $releaseDecision.Prerelease -and -not $createRelease -and -not $closedPullRequest +$createPrerelease = Test-PrereleaseCreation -ReleaseDecision $releaseDecision -PullRequestClosed:$prIsClosed $prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]' $majorRelease = $releaseDecision.Bump -eq 'Major' diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index e4f5062..a845543 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -219,4 +219,44 @@ Describe 'Resolve-ReleaseDecision' { } } } + + Describe 'Test-PrereleaseCreation' { + It 'returns for ' -ForEach @( + @{ + Name = 'an open prerelease pull request' + Labels = @('release:patch', 'release:pre-release') + Closed = $false + Expected = $true + } + @{ + Name = 'a closed prerelease pull request' + Labels = @('release:patch', 'release:pre-release') + Closed = $true + Expected = $false + } + @{ + Name = 'an open stable pull request' + Labels = @('release:patch') + Closed = $false + Expected = $false + } + @{ + Name = 'a closed stable pull request' + Labels = @('release:patch') + Closed = $true + Expected = $false + } + @{ + Name = 'an open skipped pull request' + Labels = @('release:skip') + Closed = $false + Expected = $false + } + ) { + $decision = Resolve-ReleaseDecision -Labels $Labels + $result = Test-PrereleaseCreation -ReleaseDecision $decision -PullRequestClosed:$Closed + + $result | Should -Be $Expected + } + } } From efc2b38b2798716c2657cd38da7bae00b6cb23d1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:15:25 +0200 Subject: [PATCH 08/17] Scope release concurrency to each pull request --- .github/workflows/Release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 2a04c2b..9015c21 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -15,7 +15,7 @@ on: - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true permissions: From be73e64de3d3e88491ccc45cffca4a0411d9b6e7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 23:17:31 +0200 Subject: [PATCH 09/17] Document the trusted release trigger --- .github/workflows/Release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 9015c21..b927465 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -3,7 +3,7 @@ name: Release run-name: "Release - [${{ github.event.pull_request.title }} #${{ github.event.pull_request.number }}] by @${{ github.actor }}" on: - pull_request_target: + pull_request_target: # zizmor: ignore[dangerous-triggers] trusted base checkout; writes labels and releases branches: - main types: From a1c67289aa034aa91be80fc790afffe7105f8242 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:28:13 +0200 Subject: [PATCH 10/17] Resolve unlabeled releases from DefaultBump --- src/Release-GHRepository.Helpers.psm1 | 106 ++++++++-- tests/Release-GHRepository.Helpers.Tests.ps1 | 204 ++++++++++++++----- 2 files changed, 238 insertions(+), 72 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 3154e51..eb948ac 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -55,15 +55,62 @@ function Get-ReleaseLabelDefinition { } } +function ConvertTo-ReleaseBump { + <# + .SYNOPSIS + Convert a configured default bump into the internal release-bump value. + + .DESCRIPTION + Validate DefaultBump with case-sensitive matching and return the + corresponding internal Patch, Minor, or Major value. + + .EXAMPLE + ConvertTo-ReleaseBump -DefaultBump 'minor' + + Return Minor. + + .INPUTS + None + + You can't pipe objects to ConvertTo-ReleaseBump. + + .OUTPUTS + System.String + + The validated internal release-bump value. + #> + [OutputType([string])] + [CmdletBinding()] + param( + # The fallback bump used when no explicit release bump or skip label exists. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $DefaultBump = 'patch' + ) + + $validDefaultBumps = @('patch', 'minor', 'major') + if ($validDefaultBumps -cnotcontains $DefaultBump) { + throw "Invalid DefaultBump [$DefaultBump]. Use exactly one of: patch, minor, major." + } + + switch -CaseSensitive ($DefaultBump) { + 'patch' { 'Patch' } + 'minor' { 'Minor' } + 'major' { 'Major' } + } +} + function Resolve-ReleaseDecision { <# .SYNOPSIS Resolve a release decision from canonical pull-request labels. .DESCRIPTION - Evaluate only the five labels owned by Release-GHRepository. Return one - explicit bump or skip decision and reject missing or conflicting owned-label - combinations without applying a default. + Evaluate only the five labels owned by Release-GHRepository. An explicit + bump label overrides DefaultBump, release:pre-release selects prerelease + mode, and release:skip suppresses publication. Reject conflicting owned-label + combinations. .EXAMPLE Resolve-ReleaseDecision -Labels @('release:minor') @@ -75,6 +122,11 @@ function Resolve-ReleaseDecision { Return a Patch prerelease decision. + .EXAMPLE + Resolve-ReleaseDecision -Labels @() -DefaultBump 'minor' + + Return a Minor stable-release decision from the configured default. + .INPUTS None @@ -92,9 +144,16 @@ function Resolve-ReleaseDecision { [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowNull()] - [string[]] $Labels + [string[]] $Labels, + + # The fallback bump used when no explicit release bump or skip label exists. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [string] $DefaultBump = 'patch' ) + $resolvedDefaultBump = ConvertTo-ReleaseBump -DefaultBump $DefaultBump $bumpLabelTypes = [ordered]@{ 'release:patch' = 'Patch' 'release:minor' = 'Minor' @@ -121,32 +180,30 @@ function Resolve-ReleaseDecision { } [PSCustomObject]@{ - Bump = 'None' - Prerelease = $false - Skip = $true + Bump = 'None' + Prerelease = $false + Skip = $true + DefaultBumpApplied = $false } return } - if ($bumpLabels.Count -eq 0) { - if ($hasPrerelease) { - throw 'Invalid release labels: release:pre-release requires exactly one release bump label.' - } - - throw ( - 'Release decision is missing. Apply exactly one of release:patch, release:minor, ' + - 'release:major, or release:skip.' - ) - } - if ($bumpLabels.Count -gt 1) { throw "Conflicting release bump labels: [$($bumpLabels -join ', ')]. Apply exactly one bump label." } + $defaultBumpApplied = $bumpLabels.Count -eq 0 + $bump = if ($defaultBumpApplied) { + $resolvedDefaultBump + } else { + $bumpLabelTypes[$bumpLabels[0]] + } + [PSCustomObject]@{ - Bump = $bumpLabelTypes[$bumpLabels[0]] - Prerelease = $hasPrerelease - Skip = $false + Bump = $bump + Prerelease = $hasPrerelease + Skip = $false + DefaultBumpApplied = $defaultBumpApplied } } @@ -196,4 +253,9 @@ function Test-PrereleaseCreation { $ReleaseDecision.Prerelease -and -not $ReleaseDecision.Skip -and -not $PullRequestClosed } -Export-ModuleMember -Function Get-ReleaseLabelDefinition, Resolve-ReleaseDecision, Test-PrereleaseCreation +Export-ModuleMember -Function @( + 'ConvertTo-ReleaseBump' + 'Get-ReleaseLabelDefinition' + 'Resolve-ReleaseDecision' + 'Test-PrereleaseCreation' +) diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index a845543..15edaa3 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -44,6 +44,31 @@ Describe 'Get-ReleaseLabelDefinition' { } } +Describe 'ConvertTo-ReleaseBump' { + It 'converts to ' -ForEach @( + @{ DefaultBump = 'patch'; Expected = 'Patch' } + @{ DefaultBump = 'minor'; Expected = 'Minor' } + @{ DefaultBump = 'major'; Expected = 'Major' } + ) { + ConvertTo-ReleaseBump -DefaultBump $DefaultBump | Should -BeExactly $Expected + } + + It 'uses patch when DefaultBump is omitted' { + ConvertTo-ReleaseBump | Should -BeExactly 'Patch' + } + + It 'rejects invalid DefaultBump []' -ForEach @( + @{ DefaultBump = '' } + @{ DefaultBump = 'Patch' } + @{ DefaultBump = 'prerelease' } + @{ DefaultBump = 'none' } + @{ DefaultBump = $null } + ) { + { ConvertTo-ReleaseBump -DefaultBump $DefaultBump } | + Should -Throw '*Invalid DefaultBump*patch, minor, major*' + } +} + Describe 'Resolve-ReleaseDecision' { It 'resolves ' -ForEach @( @{ @@ -115,44 +140,10 @@ Describe 'Resolve-ReleaseDecision' { $result.Bump | Should -BeExactly $Bump $result.Prerelease | Should -Be $Prerelease $result.Skip | Should -Be $Skip + $result.DefaultBumpApplied | Should -BeFalse } It 'rejects ' -ForEach @( - @{ - Name = 'an empty label set' - Labels = @() - Message = '*Release decision is missing*' - } - @{ - Name = 'a null label set' - Labels = $null - Message = '*Release decision is missing*' - } - @{ - Name = 'legacy bare labels' - Labels = @('Major', 'Minor', 'Patch', 'Prerelease', 'NoRelease') - Message = '*Release decision is missing*' - } - @{ - Name = 'lowercase bare labels' - Labels = @('major', 'minor', 'patch', 'prerelease') - Message = '*Release decision is missing*' - } - @{ - Name = 'noncanonical casing' - Labels = @('Release:Patch') - Message = '*Release decision is missing*' - } - @{ - Name = 'an unknown release label' - Labels = @('release:unknown') - Message = '*Release decision is missing*' - } - @{ - Name = 'prerelease without a bump' - Labels = @('release:pre-release') - Message = '*release:pre-release requires exactly one release bump label*' - } @{ Name = 'patch and minor bumps' Labels = @('release:patch', 'release:minor') @@ -182,7 +173,116 @@ Describe 'Resolve-ReleaseDecision' { { Resolve-ReleaseDecision -Labels $Labels } | Should -Throw $Message } - It 'accepts exactly the seven valid subsets of owned release labels' { + It 'resolves from with the default' -ForEach @( + @{ + Name = 'an empty label set' + Labels = @() + DefaultBump = 'patch' + Expected = 'Patch' + Prerelease = $false + } + @{ + Name = 'a null label set' + Labels = $null + DefaultBump = 'minor' + Expected = 'Minor' + Prerelease = $false + } + @{ + Name = 'legacy bare labels' + Labels = @('Major', 'Minor', 'Patch', 'Prerelease', 'NoRelease') + DefaultBump = 'major' + Expected = 'Major' + Prerelease = $false + } + @{ + Name = 'lowercase bare labels' + Labels = @('major', 'minor', 'patch', 'prerelease') + DefaultBump = 'patch' + Expected = 'Patch' + Prerelease = $false + } + @{ + Name = 'noncanonical casing' + Labels = @('Release:Patch') + DefaultBump = 'minor' + Expected = 'Minor' + Prerelease = $false + } + @{ + Name = 'an unknown release label' + Labels = @('release:unknown') + DefaultBump = 'major' + Expected = 'Major' + Prerelease = $false + } + @{ + Name = 'prerelease without an explicit bump' + Labels = @('release:pre-release') + DefaultBump = 'minor' + Expected = 'Minor' + Prerelease = $true + } + ) { + $result = Resolve-ReleaseDecision -Labels $Labels -DefaultBump $DefaultBump + + $result.Bump | Should -BeExactly $Expected + $result.Prerelease | Should -Be $Prerelease + $result.Skip | Should -BeFalse + $result.DefaultBumpApplied | Should -BeTrue + } + + It 'applies each valid default to unlabeled and prerelease-only decisions' { + $expectedBumps = @{ + patch = 'Patch' + minor = 'Minor' + major = 'Major' + } + + foreach ($defaultBump in @('patch', 'minor', 'major')) { + foreach ($labels in @(@(), @('release:pre-release'))) { + $result = Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump + + $result.Bump | Should -BeExactly $expectedBumps[$defaultBump] + $result.Prerelease | Should -Be ($labels -ccontains 'release:pre-release') + $result.DefaultBumpApplied | Should -BeTrue + } + } + } + + It 'lets every explicit bump label override every valid default' { + $explicitBumps = [ordered]@{ + 'release:patch' = 'Patch' + 'release:minor' = 'Minor' + 'release:major' = 'Major' + } + + foreach ($defaultBump in @('patch', 'minor', 'major')) { + foreach ($entry in $explicitBumps.GetEnumerator()) { + $result = Resolve-ReleaseDecision -Labels @($entry.Key) -DefaultBump $defaultBump + + $result.Bump | Should -BeExactly $entry.Value + $result.DefaultBumpApplied | Should -BeFalse + } + } + } + + It 'lets release:skip override every valid default' { + foreach ($defaultBump in @('patch', 'minor', 'major')) { + $result = Resolve-ReleaseDecision -Labels @('release:skip') -DefaultBump $defaultBump + + $result.Bump | Should -BeExactly 'None' + $result.Skip | Should -BeTrue + $result.DefaultBumpApplied | Should -BeFalse + } + } + + It 'validates DefaultBump before applying an explicit decision' { + { Resolve-ReleaseDecision -Labels @('release:patch') -DefaultBump 'Patch' } | + Should -Throw '*Invalid DefaultBump*' + } + + It 'accepts exactly the nine valid subsets for every default bump' { $ownedLabels = @( 'release:patch' 'release:minor' @@ -191,6 +291,8 @@ Describe 'Resolve-ReleaseDecision' { 'release:skip' ) $validSubsets = @( + '' + 'release:pre-release' 'release:patch' 'release:minor' 'release:major' @@ -200,22 +302,24 @@ Describe 'Resolve-ReleaseDecision' { 'release:skip' ) - for ($mask = 0; $mask -lt (1 -shl $ownedLabels.Count); $mask++) { - $labels = @( - for ($index = 0; $index -lt $ownedLabels.Count; $index++) { - if (($mask -band (1 -shl $index)) -ne 0) { - $ownedLabels[$index] + foreach ($defaultBump in @('patch', 'minor', 'major')) { + for ($mask = 0; $mask -lt (1 -shl $ownedLabels.Count); $mask++) { + $labels = @( + for ($index = 0; $index -lt $ownedLabels.Count; $index++) { + if (($mask -band (1 -shl $index)) -ne 0) { + $ownedLabels[$index] + } } - } - ) - $subset = ($labels | Sort-Object) -join ',' + ) + $subset = ($labels | Sort-Object) -join ',' - if ($validSubsets -ccontains $subset) { - { Resolve-ReleaseDecision -Labels $labels } | - Should -Not -Throw -Because "[$subset] is a valid release decision" - } else { - { Resolve-ReleaseDecision -Labels $labels } | - Should -Throw -Because "[$subset] is not a valid release decision" + if ($validSubsets -ccontains $subset) { + { Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump } | + Should -Not -Throw -Because "[$subset] is valid with DefaultBump [$defaultBump]" + } else { + { Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump } | + Should -Throw -Because "[$subset] is not a valid release decision" + } } } } From 9f13a19de4e789007209fabf6bea658707ba5eb1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:28:49 +0200 Subject: [PATCH 11/17] Expose DefaultBump in the action contract --- action.yml | 5 +++++ src/main.ps1 | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 0e03db5..ce39976 100644 --- a/action.yml +++ b/action.yml @@ -14,6 +14,10 @@ inputs: description: The path to the configuration file. Settings in the configuration file take precedence over the action inputs. required: false default: .github\auto-release.yml + DefaultBump: + description: The release bump to use when no explicit release bump or skip label exists. Allowed values are patch, minor, and major. + required: false + default: patch CreateMajorTag: description: Control whether to create a major tag when a pull request is merged into the main branch. required: false @@ -78,6 +82,7 @@ runs: env: PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup: ${{ inputs.AutoCleanup }} PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile: ${{ inputs.ConfigurationFile }} + PSMODULE_AUTO_RELEASE_INPUT_DefaultBump: ${{ inputs.DefaultBump }} PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag: ${{ inputs.CreateMajorTag }} PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag: ${{ inputs.CreateMinorTag }} PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat: ${{ inputs.DatePrereleaseFormat }} diff --git a/src/main.ps1 b/src/main.ps1 index 511af95..d2eb28a 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -45,6 +45,7 @@ LogGroup 'Set configuration' { } $autoCleanup = ![string]::IsNullOrEmpty($configuration.AutoCleanup) ? $configuration.AutoCleanup -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup -eq 'true' + $defaultBump = $null -ne $configuration.DefaultBump ? [string] $configuration.DefaultBump : $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump $createMajorTag = ![string]::IsNullOrEmpty($configuration.CreateMajorTag) ? $configuration.CreateMajorTag -EQ 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag -EQ 'true' $createMinorTag = ![string]::IsNullOrEmpty($configuration.CreateMinorTag) ? $configuration.CreateMinorTag -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag -eq 'true' $datePrereleaseFormat = ![string]::IsNullOrEmpty($configuration.DatePrereleaseFormat) ? $configuration.DatePrereleaseFormat : $env:PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat @@ -55,8 +56,11 @@ LogGroup 'Set configuration' { $versionPrefix = ![string]::IsNullOrEmpty($configuration.VersionPrefix) ? $configuration.VersionPrefix : $env:PSMODULE_AUTO_RELEASE_INPUT_VersionPrefix $whatIf = ![string]::IsNullOrEmpty($configuration.WhatIf) ? $configuration.WhatIf -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_WhatIf -eq 'true' + $null = ConvertTo-ReleaseBump -DefaultBump $defaultBump + Write-Output '-------------------------------------------------' Write-Output "Auto cleanup enabled: [$autoCleanup]" + Write-Output "Default bump: [$defaultBump]" Write-Output "Create major tag enabled: [$createMajorTag]" Write-Output "Create minor tag enabled: [$createMinorTag]" Write-Output "Date-based prerelease format: [$datePrereleaseFormat]" @@ -147,7 +151,7 @@ LogGroup 'Pull request - Labels' { $labels | Format-List | Out-String } -$releaseDecision = Resolve-ReleaseDecision -Labels $labels +$releaseDecision = Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump $createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged @@ -159,6 +163,7 @@ $minorRelease = $releaseDecision.Bump -eq 'Minor' $patchRelease = $releaseDecision.Bump -eq 'Patch' Write-Output '-------------------------------------------------' +Write-Output "Default bump applied: [$($releaseDecision.DefaultBumpApplied)]" Write-Output "Skip release: [$($releaseDecision.Skip)]" Write-Output "Create a release: [$createRelease]" Write-Output "Create a prerelease: [$createPrerelease]" From a3b76e0b701b8803d15266927ad4373ea5436fc3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:29:23 +0200 Subject: [PATCH 12/17] Document DefaultBump precedence and migration --- README.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 89b1bae..1c5da94 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Release-GHRepository -Create GitHub releases from explicit, owned pull-request labels. +Create GitHub releases from owned pull-request labels and a configurable default bump. ## Release decision @@ -11,25 +11,26 @@ Release-GHRepository owns and provisions five labels: | `release:patch` | Increment the patch version. | Exactly one bump label. | | `release:minor` | Increment the minor version. | Exactly one bump label. | | `release:major` | Increment the major version. | Exactly one bump label. | -| `release:pre-release` | Publish from an open pull request as a prerelease. | With exactly one bump label. | +| `release:pre-release` | Publish from an open pull request as a prerelease. | Alone or with exactly one bump label. | | `release:skip` | Validate without publishing a release. | Without another owned release label. | -Exactly one bump label or `release:skip` is required. There is no default release decision. +When no owned bump or skip label exists, `DefaultBump` selects `patch`, `minor`, or `major`. Its default is `patch`, preserving the automatic patch behavior from v2. An explicit `release:patch`, `release:minor`, or `release:major` label overrides `DefaultBump`. + +`release:pre-release` is a mode. It uses the explicit bump when one is present and otherwise uses `DefaultBump`. `release:skip` suppresses publication instead of applying the default. The action rejects: -- a missing decision; +- a `DefaultBump` value other than exactly `patch`, `minor`, or `major`; - multiple bump labels; -- `release:skip` with another owned release label; -- `release:pre-release` without exactly one bump label. +- `release:skip` with another owned release label. -Labels outside this set do not affect releases. Bare and legacy labels such as `Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`, `major`, `minor`, and `patch` are not release decisions. +Labels outside this set do not affect releases. Bare and legacy labels such as `Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`, `major`, `minor`, and `patch` are ignored, so a pull request carrying only those labels uses `DefaultBump`. ## How it works On every non-WhatIf run, the action creates missing canonical labels and reconciles their colors and descriptions. It leaves all other repository labels unchanged. -An open pull request with `release:pre-release` and one bump label publishes a prerelease. A pull request merged into the default branch with one bump label publishes the stable release. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. +An open pull request with `release:pre-release` publishes a prerelease from its explicit bump or `DefaultBump`. A pull request merged into the default branch publishes the resolved bump unless it carries `release:skip`. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. The workflow must run for `labeled` and `unlabeled` events so both valid and invalid label transitions are evaluated. Do not use a workflow path filter to bypass the release decision on non-artifact changes; use `release:skip`. @@ -81,6 +82,7 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch | --- | --- | --- | --- | | `AutoCleanup` | Delete prereleases after the pull request closes. | `true` | false | | `ConfigurationFile` | Read settings from this file. File settings take precedence over action inputs. | `.github\auto-release.yml` | false | +| `DefaultBump` | Select the bump when no explicit bump or skip label exists. Accepts exactly `patch`, `minor`, or `major`. | `patch` | false | | `CreateMajorTag` | Create or update the floating major tag after a stable release. | `true` | false | | `CreateMinorTag` | Create or update the floating minor tag after a stable release. | `true` | false | | `DatePrereleaseFormat` | Append a [.NET date and time format](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings) to prerelease versions. | `''` | false | @@ -104,6 +106,7 @@ The default configuration file is `.github\auto-release.yml`. Change its path wi ```yaml DatePrereleaseFormat: 'yyyyMMddHHmm' +DefaultBump: patch IncrementalPrerelease: false VersionPrefix: '' ``` @@ -114,11 +117,11 @@ VersionPrefix: '' 1. Add `issues: write` to the release job and subscribe the workflow to `unlabeled`. 2. Remove workflow path filters so `release:skip` decisions are validated. -3. Remove `AutoPatching`, `IgnoreLabels`, `MajorLabels`, `MinorLabels`, and `PatchLabels` from action inputs and configuration files. +3. Replace `AutoPatching` with `DefaultBump`. Use `DefaultBump: patch` to preserve the v2 `AutoPatching: true` behavior. Remove `IgnoreLabels`, `MajorLabels`, `MinorLabels`, and `PatchLabels`. 4. Provision the five canonical labels before opening the migration pull request. The action reconciles them on every subsequent run. 5. Apply both the existing v2 decision and the equivalent canonical decision to the migration pull request so either workflow version can process it. 6. Update the action reference to `PSModule/Release-GHRepository@v3` after `v3.0.0` is published. 7. Apply one canonical decision to every other open pull request. 8. Remove legacy release labels after no open pull request uses them. Removing bare `major`, `minor`, and `patch` labels also prevents Dependabot from applying them as dependency-version metadata. -The first v3 run provisions the canonical labels before validating the pull request. A pull request without a canonical decision fails until a maintainer applies one. +The first v3 run provisions the canonical labels before processing the pull request. A pull request without an owned bump or skip label uses `DefaultBump`; a maintainer applies an owned label only to override that default or skip publication. From fd2581412c99daa100d31b74f10fa223d0ff2535 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:33:50 +0200 Subject: [PATCH 13/17] Preserve terminating native command failures --- src/main.ps1 | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/main.ps1 b/src/main.ps1 index d2eb28a..72be22d 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -176,8 +176,7 @@ Write-Output '-------------------------------------------------' LogGroup 'Get releases' { $releases = gh release list --json 'createdAt,isDraft,isLatest,isPrerelease,name,publishedAt,tagName' | ConvertFrom-Json if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to list all releases for the repo.' - exit $LASTEXITCODE + throw "Failed to list all releases for the repo. gh exited with code [$LASTEXITCODE]." } $releases | Select-Object -Property name, isPrerelease, isLatest, publishedAt | Format-Table | Out-String } @@ -250,8 +249,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } else { gh release delete $newVersion --cleanup-tag --yes if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to delete the release [$newVersion]." - exit $LASTEXITCODE + throw "Failed to delete the release [$newVersion]. gh exited with code [$LASTEXITCODE]." } } } @@ -293,8 +291,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w # Execute the command and capture the output $releaseURL = gh @releaseCreateCommand if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create the release [$newVersion]." - exit $LASTEXITCODE + throw "Failed to create the release [$newVersion]. gh exited with code [$LASTEXITCODE]." } } @@ -303,8 +300,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } else { gh pr comment $pull_request.number -b "The release [$newVersion]($releaseURL) has been created." if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to comment on the pull request.' - exit $LASTEXITCODE + throw "Failed to comment on the pull request. gh exited with code [$LASTEXITCODE]." } } } else { @@ -341,8 +337,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w if (-not $whatIf) { gh @releaseCreateCommand if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create the release [$newVersion]." - exit $LASTEXITCODE + throw "Failed to create the release [$newVersion]. gh exited with code [$LASTEXITCODE]." } } @@ -353,8 +348,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } else { git tag -f $majorTag 'main' if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create major tag [$majorTag]." - exit $LASTEXITCODE + throw "Failed to create major tag [$majorTag]. git exited with code [$LASTEXITCODE]." } } } @@ -366,8 +360,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } else { git tag -f $minorTag 'main' if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create minor tag [$minorTag]." - exit $LASTEXITCODE + throw "Failed to create minor tag [$minorTag]. git exited with code [$LASTEXITCODE]." } } } @@ -377,8 +370,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } else { git push origin --tags --force if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to push tags.' - exit $LASTEXITCODE + throw "Failed to push tags. git exited with code [$LASTEXITCODE]." } } } @@ -403,8 +395,7 @@ if (($prIsClosed -and $autoCleanup) -or $whatIf) { } else { gh release delete $rel.tagName --cleanup-tag --yes if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to delete release [$relTagName]." - exit $LASTEXITCODE + throw "Failed to delete release [$relTagName]. gh exited with code [$LASTEXITCODE]." } } } From 2c194967799603688b9bad50984717308a676c96 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:38:38 +0200 Subject: [PATCH 14/17] Apply the patch default consistently --- README.md | 2 +- action.yml | 2 +- src/main.ps1 | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1c5da94..2c9f090 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch | --- | --- | --- | --- | | `AutoCleanup` | Delete prereleases after the pull request closes. | `true` | false | | `ConfigurationFile` | Read settings from this file. File settings take precedence over action inputs. | `.github\auto-release.yml` | false | -| `DefaultBump` | Select the bump when no explicit bump or skip label exists. Accepts exactly `patch`, `minor`, or `major`. | `patch` | false | +| `DefaultBump` | Select the bump when no explicit bump or skip label exists. Accepts exactly `patch`, `minor`, or `major` with case-sensitive matching. | `patch` | false | | `CreateMajorTag` | Create or update the floating major tag after a stable release. | `true` | false | | `CreateMinorTag` | Create or update the floating minor tag after a stable release. | `true` | false | | `DatePrereleaseFormat` | Append a [.NET date and time format](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings) to prerelease versions. | `''` | false | diff --git a/action.yml b/action.yml index ce39976..d9d684c 100644 --- a/action.yml +++ b/action.yml @@ -15,7 +15,7 @@ inputs: required: false default: .github\auto-release.yml DefaultBump: - description: The release bump to use when no explicit release bump or skip label exists. Allowed values are patch, minor, and major. + description: The release bump to use when no explicit release bump or skip label exists. Accepts exactly patch, minor, or major with case-sensitive matching. required: false default: patch CreateMajorTag: diff --git a/src/main.ps1 b/src/main.ps1 index 72be22d..d0e4be2 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -45,7 +45,13 @@ LogGroup 'Set configuration' { } $autoCleanup = ![string]::IsNullOrEmpty($configuration.AutoCleanup) ? $configuration.AutoCleanup -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup -eq 'true' - $defaultBump = $null -ne $configuration.DefaultBump ? [string] $configuration.DefaultBump : $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump + $defaultBump = if ($null -ne $configuration.DefaultBump) { + [string] $configuration.DefaultBump + } elseif ([string]::IsNullOrEmpty($env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump)) { + 'patch' + } else { + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump + } $createMajorTag = ![string]::IsNullOrEmpty($configuration.CreateMajorTag) ? $configuration.CreateMajorTag -EQ 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag -EQ 'true' $createMinorTag = ![string]::IsNullOrEmpty($configuration.CreateMinorTag) ? $configuration.CreateMinorTag -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag -eq 'true' $datePrereleaseFormat = ![string]::IsNullOrEmpty($configuration.DatePrereleaseFormat) ? $configuration.DatePrereleaseFormat : $env:PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat From 9d0b79cceb5f686215973c66ef3317ba44ee74bb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:45:43 +0200 Subject: [PATCH 15/17] Target prereleases at the pull request head --- README.md | 2 +- src/Release-GHRepository.Helpers.psm1 | 57 +++++++++++++++++++ src/main.ps1 | 9 ++- tests/Release-GHRepository.Helpers.Tests.ps1 | 58 ++++++++++++++++++++ 4 files changed, 122 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c9f090..db6dcf3 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Labels outside this set do not affect releases. Bare and legacy labels such as ` On every non-WhatIf run, the action creates missing canonical labels and reconciles their colors and descriptions. It leaves all other repository labels unchanged. -An open pull request with `release:pre-release` publishes a prerelease from its explicit bump or `DefaultBump`. A pull request merged into the default branch publishes the resolved bump unless it carries `release:skip`. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. +An open pull request with `release:pre-release` publishes a prerelease from its explicit bump or `DefaultBump`. The prerelease name uses the head branch, while its tag targets the exact pull-request head commit. A pull request merged into the default branch publishes the resolved bump unless it carries `release:skip`. A closed pull request cleans up its prereleases when `AutoCleanup` is enabled. `release:skip` never publishes a version, but a closed skipped pull request still receives prerelease cleanup. The workflow must run for `labeled` and `unlabeled` events so both valid and invalid label transitions are evaluated. Do not use a workflow path filter to bypass the release decision on non-artifact changes; use `release:skip`. diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index eb948ac..afbf661 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -207,6 +207,62 @@ function Resolve-ReleaseDecision { } } +function Resolve-PullRequestReleaseContext { + <# + .SYNOPSIS + Resolve branch-derived naming and the immutable target for a pull request. + + .DESCRIPTION + Keep the pull-request head branch for prerelease naming while selecting + the exact head commit SHA as the release target. + + .EXAMPLE + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = 'feature/example' + sha = '0123456789012345678901234567890123456789' + } + } + Resolve-PullRequestReleaseContext -PullRequest $pullRequest + + Return a prerelease name of featureexample and the supplied commit SHA as + the prerelease target. + + .INPUTS + None + + You can't pipe objects to Resolve-PullRequestReleaseContext. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The validated pull-request release context. + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + # The pull request from the GitHub event payload. + [Parameter(Mandatory)] + [PSCustomObject] $PullRequest + ) + + $headRef = [string] $PullRequest.head.ref + $headSha = [string] $PullRequest.head.sha + + if ([string]::IsNullOrWhiteSpace($headRef)) { + throw 'Pull request head ref is required.' + } + if ([string]::IsNullOrWhiteSpace($headSha)) { + throw 'Pull request head SHA is required.' + } + + [PSCustomObject]@{ + HeadRef = $headRef + PrereleaseName = $headRef -replace '[^a-zA-Z0-9]' + PrereleaseTarget = $headSha + } +} + function Test-PrereleaseCreation { <# .SYNOPSIS @@ -257,5 +313,6 @@ Export-ModuleMember -Function @( 'ConvertTo-ReleaseBump' 'Get-ReleaseLabelDefinition' 'Resolve-ReleaseDecision' + 'Resolve-PullRequestReleaseContext' 'Test-PrereleaseCreation' ) diff --git a/src/main.ps1 b/src/main.ps1 index d0e4be2..bcde24b 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -133,7 +133,9 @@ $actionType = $githubEvent.action $isMerged = ($pull_request.merged).ToString() -eq 'True' $prIsClosed = $pull_request.state -eq 'closed' $prBaseRef = $pull_request.base.ref -$prHeadRef = $pull_request.head.ref +$pullRequestReleaseContext = Resolve-PullRequestReleaseContext -PullRequest $pull_request +$prHeadRef = $pullRequestReleaseContext.HeadRef +$prereleaseTarget = $pullRequestReleaseContext.PrereleaseTarget $targetIsDefaultBranch = $pull_request.base.ref -eq $defaultBranchName Write-Output '-------------------------------------------------' @@ -144,6 +146,7 @@ Write-Output "PR Merged: [$isMerged]" Write-Output "PR Closed: [$prIsClosed]" Write-Output "PR Base Ref: [$prBaseRef]" Write-Output "PR Head Ref: [$prHeadRef]" +Write-Output "PR Head SHA: [$prereleaseTarget]" Write-Output "Target is default branch: [$targetIsDefaultBranch]" Write-Output '-------------------------------------------------' @@ -162,7 +165,7 @@ $releaseDecision = Resolve-ReleaseDecision -Labels $labels -DefaultBump $default $createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged $createPrerelease = Test-PrereleaseCreation -ReleaseDecision $releaseDecision -PullRequestClosed:$prIsClosed -$prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]' +$prereleaseName = $pullRequestReleaseContext.PrereleaseName $majorRelease = $releaseDecision.Bump -eq 'Major' $minorRelease = $releaseDecision.Bump -eq 'Minor' @@ -290,7 +293,7 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } # Add remaining parameters - $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') + $releaseCreateCommand += @('--target', $prereleaseTarget, '--prerelease') Write-Output "gh $($releaseCreateCommand -join ' ')" if (-not $whatIf) { diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 15edaa3..d081537 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -324,6 +324,64 @@ Describe 'Resolve-ReleaseDecision' { } } + Describe 'Resolve-PullRequestReleaseContext' { + It 'uses the branch for naming and the exact head SHA for the release target' { + $headSha = '0123456789012345678901234567890123456789' + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = 'feature/fork-release' + sha = $headSha + } + } + + $result = Resolve-PullRequestReleaseContext -PullRequest $pullRequest + + $result.HeadRef | Should -BeExactly 'feature/fork-release' + $result.PrereleaseName | Should -BeExactly 'featureforkrelease' + $result.PrereleaseTarget | Should -BeExactly $headSha + } + + It 'does not use a colliding base-repository branch name as the release target' { + $headSha = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd' + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = 'main' + sha = $headSha + } + } + + $result = Resolve-PullRequestReleaseContext -PullRequest $pullRequest + + $result.PrereleaseName | Should -BeExactly 'main' + $result.PrereleaseTarget | Should -BeExactly $headSha + $result.PrereleaseTarget | Should -Not -BeExactly $result.HeadRef + } + + It 'rejects a pull request without a head ref' { + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = '' + sha = '0123456789012345678901234567890123456789' + } + } + + { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | + Should -Throw '*head ref is required*' + } + + It 'rejects a pull request without a head SHA' { + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = 'feature/missing-sha' + sha = '' + } + } + + { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | + Should -Throw '*head SHA is required*' + } + } + Describe 'Test-PrereleaseCreation' { It 'returns for ' -ForEach @( @{ From e3d91ea79056398a7f93195733bf4cf9621a9482 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:49:41 +0200 Subject: [PATCH 16/17] Render concrete dry-run comment commands --- src/main.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main.ps1 b/src/main.ps1 index bcde24b..94fede5 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -305,7 +305,8 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } if ($whatIf) { - Write-Output 'WhatIf: gh pr comment $pull_request.number -b "The release [$newVersion] has been created."' + $commentPreview = 'WhatIf: gh pr comment {0} -b "The release [{1}] has been created."' -f $pull_request.number, $newVersion + Write-Output $commentPreview } else { gh pr comment $pull_request.number -b "The release [$newVersion]($releaseURL) has been created." if ($LASTEXITCODE -ne 0) { From eb79780f1da37a6f43e315f96a9a29f7c3bd07cf Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 29 Aug 2026 14:53:15 +0200 Subject: [PATCH 17/17] Fail clearly on invalid prerelease context --- src/Release-GHRepository.Helpers.psm1 | 7 ++++++- src/main.ps1 | 2 +- tests/Release-GHRepository.Helpers.Tests.ps1 | 12 ++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index afbf661..0389791 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -256,9 +256,14 @@ function Resolve-PullRequestReleaseContext { throw 'Pull request head SHA is required.' } + $prereleaseName = $headRef -replace '[^a-zA-Z0-9]' + if ([string]::IsNullOrWhiteSpace($prereleaseName)) { + throw "Pull request head ref [$headRef] does not contain a valid prerelease identifier." + } + [PSCustomObject]@{ HeadRef = $headRef - PrereleaseName = $headRef -replace '[^a-zA-Z0-9]' + PrereleaseName = $prereleaseName PrereleaseTarget = $headSha } } diff --git a/src/main.ps1 b/src/main.ps1 index 94fede5..c15261e 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -102,7 +102,7 @@ LogGroup 'Provision release labels' { } else { gh @arguments if ($LASTEXITCODE -ne 0) { - throw "Failed to provision the canonical label [$($definition.Name)]." + throw "Failed to provision the canonical label [$($definition.Name)]. gh exited with code [$LASTEXITCODE]." } } } diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index d081537..85ebd4e 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -380,6 +380,18 @@ Describe 'Resolve-ReleaseDecision' { { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | Should -Throw '*head SHA is required*' } + + It 'rejects a head ref that has no valid prerelease identifier' { + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = '---' + sha = '0123456789012345678901234567890123456789' + } + } + + { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | + Should -Throw '*does not contain a valid prerelease identifier*' + } } Describe 'Test-PrereleaseCreation' {