From ee24a8dd49a5b143cc154b1207a7fb8155df41d3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 28 Aug 2026 22:57:14 +0200 Subject: [PATCH 01/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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' { From 0265cf5d51d95ab9c766fa280d3130ddb94c2033 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 18:30:00 +0200 Subject: [PATCH 18/27] Use the release prerelease qualifier --- src/Release-GHRepository.Helpers.psm1 | 14 +++---- tests/Release-GHRepository.Helpers.Tests.ps1 | 39 ++++++++++++-------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 0389791..e77fab9 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -44,7 +44,7 @@ function Get-ReleaseLabelDefinition { Description = 'Publish a major release.' } [PSCustomObject]@{ - Name = 'release:pre-release' + Name = 'release:prerelease' Color = '8d7bdf' Description = 'Publish a prerelease from this open pull request.' } @@ -108,7 +108,7 @@ function Resolve-ReleaseDecision { .DESCRIPTION Evaluate only the five labels owned by Release-GHRepository. An explicit - bump label overrides DefaultBump, release:pre-release selects prerelease + bump label overrides DefaultBump, release:prerelease selects prerelease mode, and release:skip suppresses publication. Reject conflicting owned-label combinations. @@ -118,7 +118,7 @@ function Resolve-ReleaseDecision { Return a Minor stable-release decision. .EXAMPLE - Resolve-ReleaseDecision -Labels @('release:patch', 'release:pre-release') + Resolve-ReleaseDecision -Labels @('release:patch', 'release:prerelease') Return a Patch prerelease decision. @@ -159,7 +159,7 @@ function Resolve-ReleaseDecision { 'release:minor' = 'Minor' 'release:major' = 'Major' } - $ownedLabelNames = @($bumpLabelTypes.Keys) + @('release:pre-release', 'release:skip') + $ownedLabelNames = @($bumpLabelTypes.Keys) + @('release:prerelease', 'release:skip') $ownedLabels = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::Ordinal ) @@ -171,7 +171,7 @@ function Resolve-ReleaseDecision { } $hasSkip = $ownedLabels.Contains('release:skip') - $hasPrerelease = $ownedLabels.Contains('release:pre-release') + $hasPrerelease = $ownedLabels.Contains('release:prerelease') $bumpLabels = @($bumpLabelTypes.Keys | Where-Object { $ownedLabels.Contains($_) }) if ($hasSkip) { @@ -278,13 +278,13 @@ function Test-PrereleaseCreation { and the pull request remains open. .EXAMPLE - $decision = Resolve-ReleaseDecision -Labels @('release:patch', 'release:pre-release') + $decision = Resolve-ReleaseDecision -Labels @('release:patch', 'release:prerelease') 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') + $decision = Resolve-ReleaseDecision -Labels @('release:patch', 'release:prerelease') Test-PrereleaseCreation -ReleaseDecision $decision -PullRequestClosed Return false after the pull request closes. diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 85ebd4e..893b5f0 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -20,7 +20,7 @@ Describe 'Get-ReleaseLabelDefinition' { 'release:patch' 'release:minor' 'release:major' - 'release:pre-release' + 'release:prerelease' 'release:skip' ) } @@ -33,7 +33,7 @@ Describe 'Get-ReleaseLabelDefinition' { @{ Name = 'release:patch' } @{ Name = 'release:minor' } @{ Name = 'release:major' } - @{ Name = 'release:pre-release' } + @{ Name = 'release:prerelease' } @{ Name = 'release:skip' } ) { $definition = $definitions | Where-Object Name -CEQ $Name @@ -94,21 +94,21 @@ Describe 'Resolve-ReleaseDecision' { } @{ Name = 'a patch prerelease' - Labels = @('release:patch', 'release:pre-release') + Labels = @('release:patch', 'release:prerelease') Bump = 'Patch' Prerelease = $true Skip = $false } @{ Name = 'a minor prerelease' - Labels = @('release:minor', 'release:pre-release') + Labels = @('release:minor', 'release:prerelease') Bump = 'Minor' Prerelease = $true Skip = $false } @{ Name = 'a major prerelease' - Labels = @('release:major', 'release:pre-release') + Labels = @('release:major', 'release:prerelease') Bump = 'Major' Prerelease = $true Skip = $false @@ -166,7 +166,7 @@ Describe 'Resolve-ReleaseDecision' { } @{ Name = 'skip and prerelease' - Labels = @('release:skip', 'release:pre-release') + Labels = @('release:skip', 'release:prerelease') Message = '*release:skip must not be combined*' } ) { @@ -217,8 +217,15 @@ Describe 'Resolve-ReleaseDecision' { Prerelease = $false } @{ - Name = 'prerelease without an explicit bump' + Name = 'the retired release:pre-release label' Labels = @('release:pre-release') + DefaultBump = 'patch' + Expected = 'Patch' + Prerelease = $false + } + @{ + Name = 'prerelease without an explicit bump' + Labels = @('release:prerelease') DefaultBump = 'minor' Expected = 'Minor' Prerelease = $true @@ -240,11 +247,11 @@ Describe 'Resolve-ReleaseDecision' { } foreach ($defaultBump in @('patch', 'minor', 'major')) { - foreach ($labels in @(@(), @('release:pre-release'))) { + foreach ($labels in @(@(), @('release:prerelease'))) { $result = Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump $result.Bump | Should -BeExactly $expectedBumps[$defaultBump] - $result.Prerelease | Should -Be ($labels -ccontains 'release:pre-release') + $result.Prerelease | Should -Be ($labels -ccontains 'release:prerelease') $result.DefaultBumpApplied | Should -BeTrue } } @@ -287,18 +294,18 @@ Describe 'Resolve-ReleaseDecision' { 'release:patch' 'release:minor' 'release:major' - 'release:pre-release' + 'release:prerelease' 'release:skip' ) $validSubsets = @( '' - 'release:pre-release' + 'release:prerelease' 'release:patch' 'release:minor' 'release:major' - 'release:patch,release:pre-release' - 'release:minor,release:pre-release' - 'release:major,release:pre-release' + 'release:patch,release:prerelease' + 'release:minor,release:prerelease' + 'release:major,release:prerelease' 'release:skip' ) @@ -398,13 +405,13 @@ Describe 'Resolve-ReleaseDecision' { It 'returns for ' -ForEach @( @{ Name = 'an open prerelease pull request' - Labels = @('release:patch', 'release:pre-release') + Labels = @('release:patch', 'release:prerelease') Closed = $false Expected = $true } @{ Name = 'a closed prerelease pull request' - Labels = @('release:patch', 'release:pre-release') + Labels = @('release:patch', 'release:prerelease') Closed = $true Expected = $false } From 2f556154c6748ce6c65a63e5fae00511e4dba484 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 18:30:28 +0200 Subject: [PATCH 19/27] Document the release prerelease qualifier --- README.md | 10 +++++----- action.yml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index db6dcf3..9d72072 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ 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. | Alone or with exactly one bump label. | +| `release:prerelease` | 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. | 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. +`release:prerelease` 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: @@ -24,13 +24,13 @@ The action rejects: - multiple bump labels; - `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 ignored, so a pull request carrying only those labels uses `DefaultBump`. +Labels outside this set do not affect releases. Bare and legacy labels such as `Major`, `Minor`, `Patch`, `Prerelease`, `NoRelease`, `major`, `minor`, `patch`, and the retired `release:pre-release` 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` 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. +An open pull request with `release:prerelease` 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`. @@ -98,7 +98,7 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch | `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. +Use the `release:prerelease` label to select repository prerelease behavior. The similarly named `Prerelease` action input only controls dependency resolution for the GitHub module used internally. ### Configuration file diff --git a/action.yml b/action.yml index d9d684c..d2189ef 100644 --- a/action.yml +++ b/action.yml @@ -66,7 +66,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 of the GitHub module dependency. This does not control repository releases; use the release:pre-release label. + description: Allow prerelease versions of the GitHub module dependency. This does not control repository releases; use the release:prerelease label. required: false default: 'false' WorkingDirectory: From bae3d66cdd28b5e8bab29cac9abf66afae9bcaad Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Fri, 4 Sep 2026 18:37:50 +0200 Subject: [PATCH 20/27] Restrict cleanup to matching prereleases --- src/Release-GHRepository.Helpers.psm1 | 50 ++++++++++++ src/main.ps1 | 4 +- tests/Release-GHRepository.Helpers.Tests.ps1 | 82 ++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index e77fab9..714cdaa 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -55,6 +55,55 @@ function Get-ReleaseLabelDefinition { } } +function Get-PrereleaseCleanupCandidate { + <# + .SYNOPSIS + Return prereleases that belong to a pull-request prerelease identifier. + + .DESCRIPTION + Select only releases marked as prereleases whose tag contains the exact + prerelease identifier immediately after the SemVer hyphen. The identifier + must end the tag or be followed by a dot. + + .EXAMPLE + Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName 'feature123' + + Return prereleases tagged with -feature123 or -feature123.. + + .INPUTS + None + + You can't pipe objects to Get-PrereleaseCleanupCandidate. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + A prerelease that belongs to the requested identifier. + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + # The GitHub release records to inspect. + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowNull()] + [PSCustomObject[]] $Releases, + + # The normalized pull-request prerelease identifier. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $PrereleaseName + ) + + $escapedPrereleaseName = [regex]::Escape($PrereleaseName) + $tagPattern = "-$escapedPrereleaseName(?:\.|$)" + + $Releases | Where-Object { + $_.isPrerelease -eq $true -and + [string] $_.tagName -match $tagPattern + } +} + function ConvertTo-ReleaseBump { <# .SYNOPSIS @@ -316,6 +365,7 @@ function Test-PrereleaseCreation { Export-ModuleMember -Function @( 'ConvertTo-ReleaseBump' + 'Get-PrereleaseCleanupCandidate' 'Get-ReleaseLabelDefinition' 'Resolve-ReleaseDecision' 'Resolve-PullRequestReleaseContext' diff --git a/src/main.ps1 b/src/main.ps1 index c15261e..b95f33a 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -391,7 +391,9 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } LogGroup 'List prereleases using the same name' { - $prereleasesToCleanup = $releases | Where-Object { $_.tagName -like "*$prereleaseName*" } + $prereleasesToCleanup = @( + Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName $prereleaseName + ) $prereleasesToCleanup | Select-Object -Property name, publishedAt, isPrerelease, isLatest | Format-Table | Out-String } diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 893b5f0..40c6515 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -44,6 +44,88 @@ Describe 'Get-ReleaseLabelDefinition' { } } +Describe 'Get-PrereleaseCleanupCandidate' { + BeforeAll { + $releases = @( + [PSCustomObject]@{ + tagName = 'v1.2.3-123.1' + isPrerelease = $true + } + [PSCustomObject]@{ + tagName = 'v1.2.3-123' + isPrerelease = $true + } + [PSCustomObject]@{ + tagName = 'v1.2.3-1234.1' + isPrerelease = $true + } + [PSCustomObject]@{ + tagName = 'v1.2.3-other.123.1' + isPrerelease = $true + } + [PSCustomObject]@{ + tagName = 'v1.2.3' + isPrerelease = $false + } + [PSCustomObject]@{ + tagName = 'v123.0.0' + isPrerelease = $false + } + [PSCustomObject]@{ + tagName = 'v1.2.3-123.2' + isPrerelease = $false + } + ) + } + + It 'returns only prereleases with the exact numeric identifier segment' { + $result = @(Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName '123') + + $result.tagName | Should -Be @('v1.2.3-123.1', 'v1.2.3-123') + } + + It 'never returns stable releases whose versions overlap the identifier' { + $result = @(Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName '123') + + $result | Where-Object { -not $_.isPrerelease } | Should -BeNullOrEmpty + $result.tagName | Should -Not -Contain 'v1.2.3' + $result.tagName | Should -Not -Contain 'v123.0.0' + $result.tagName | Should -Not -Contain 'v1.2.3-123.2' + } + + It 'does not match a longer or later prerelease identifier segment' { + $result = @(Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName '123') + + $result.tagName | Should -Not -Contain 'v1.2.3-1234.1' + $result.tagName | Should -Not -Contain 'v1.2.3-other.123.1' + } + + It 'escapes regular-expression characters in the identifier' { + $regexReleases = @( + [PSCustomObject]@{ + tagName = 'v1.2.3-feature.test.1' + isPrerelease = $true + } + [PSCustomObject]@{ + tagName = 'v1.2.3-featureXtest.1' + isPrerelease = $true + } + ) + + $result = @( + Get-PrereleaseCleanupCandidate -Releases $regexReleases -PrereleaseName 'feature.test' + ) + + $result.tagName | Should -Be @('v1.2.3-feature.test.1') + } + + It 'returns no candidates for an empty release list' { + $result = @(Get-PrereleaseCleanupCandidate -Releases @() -PrereleaseName 'feature') + + $result | Should -BeNullOrEmpty + } +} + Describe 'ConvertTo-ReleaseBump' { It 'converts to ' -ForEach @( @{ DefaultBump = 'patch'; Expected = 'Patch' } From ed54c8ff37ec2aa783e2df5e797ad2a56f6df1f6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 12:48:26 +0100 Subject: [PATCH 21/27] Resolve effective settings without losing explicit overrides --- src/Release-GHRepository.Helpers.psm1 | 109 +++++++++++ src/main.ps1 | 76 +++++--- tests/Release-GHRepository.Helpers.Tests.ps1 | 191 +++++++++++++++++++ 3 files changed, 352 insertions(+), 24 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 714cdaa..5d7c0f8 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -1,5 +1,113 @@ $ErrorActionPreference = 'Stop' +function Resolve-ReleaseConfiguration { + <# + .SYNOPSIS + Resolve validated release settings from interface defaults and overrides. + + .DESCRIPTION + Apply action inputs over the defaults declared in action.yml, then apply + configuration-file settings over those inputs. Preserve explicit false + and empty string values. Reject invalid and retired settings before + repository state can change. + + .EXAMPLE + $parameters = @{ + Defaults = $actionDefaults + Inputs = @{ DefaultBump = 'patch' } + Configuration = @{ DefaultBump = 'minor' } + } + Resolve-ReleaseConfiguration @parameters + + Return the effective release settings with DefaultBump set to minor. + + .INPUTS + None + + You can't pipe objects to Resolve-ReleaseConfiguration. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The validated effective release settings. + #> + [OutputType([PSCustomObject])] + [CmdletBinding()] + param( + # The controller input defaults from action.yml. + [Parameter(Mandatory)] + [System.Collections.IDictionary] $Defaults, + + # The action inputs explicitly supplied by the runtime. + [Parameter()] + [AllowNull()] + [System.Collections.IDictionary] $Inputs, + + # The parsed configuration file, which must be a mapping when supplied. + [Parameter()] + [AllowNull()] + [object] $Configuration + ) + + if ($null -ne $Configuration -and $Configuration -isnot [System.Collections.IDictionary]) { + throw 'Release configuration must be a mapping of supported settings.' + } + + $retiredSettings = @('AutoPatching', 'IgnoreLabels', 'MajorLabels', 'MinorLabels', 'PatchLabels') + foreach ($mapping in @($Inputs, $Configuration)) { + if ($null -eq $mapping) { + continue + } + foreach ($name in $mapping.Keys) { + if ($retiredSettings -contains $name) { + throw "Release setting [$name] was removed. Use DefaultBump and the owned release: labels." + } + if ($Defaults.Keys -notcontains $name) { + throw "Unsupported release setting [$name]. Use the settings documented in action.yml." + } + } + } + + $booleanSettings = @( + 'AutoCleanup' + 'CreateMajorTag' + 'CreateMinorTag' + 'IncrementalPrerelease' + 'UsePRTitleAsReleaseName' + 'UsePRBodyAsReleaseNotes' + 'UsePRTitleAsNotesHeading' + 'WhatIf' + ) + $settings = [ordered]@{} + foreach ($name in $Defaults.Keys) { + $value = $Defaults[$name] + if ($null -ne $Inputs -and $Inputs.Contains($name)) { + $value = $Inputs[$name] + } + if ($null -ne $Configuration -and $Configuration.Contains($name)) { + $value = $Configuration[$name] + } + + if ($booleanSettings -contains $name) { + if ($value -is [bool]) { + $settings[$name] = $value + } elseif ($value -is [string] -and @('true', 'false') -contains $value) { + $settings[$name] = $value -eq 'true' + } else { + throw "Invalid release setting [$name]. Use true or false." + } + } else { + if ($value -isnot [string]) { + throw "Invalid release setting [$name]. Use a string value." + } + $settings[$name] = $value + } + } + + $null = ConvertTo-ReleaseBump -DefaultBump $settings.DefaultBump + [PSCustomObject] $settings +} + function Get-ReleaseLabelDefinition { <# .SYNOPSIS @@ -368,6 +476,7 @@ Export-ModuleMember -Function @( 'Get-PrereleaseCleanupCandidate' 'Get-ReleaseLabelDefinition' 'Resolve-ReleaseDecision' + 'Resolve-ReleaseConfiguration' 'Resolve-PullRequestReleaseContext' 'Test-PrereleaseCreation' ) diff --git a/src/main.ps1 b/src/main.ps1 index b95f33a..42eaa3f 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -37,32 +37,61 @@ LogGroup 'Loading libraries' { } LogGroup 'Set configuration' { - if (-not (Test-Path -Path $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile -PathType Leaf)) { - Write-Output "Configuration file not found at [$env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile]" - } else { - Write-Output "Reading from configuration file [$env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile]" - $configuration = ConvertFrom-Yaml -Yaml (Get-Content $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile -Raw) + $actionDefinitionPath = Join-Path -Path $PSScriptRoot -ChildPath '..\action.yml' + $actionDefinition = ConvertFrom-Yaml -Yaml (Get-Content -LiteralPath $actionDefinitionPath -Raw) + $settingNames = @( + 'AutoCleanup' + 'DefaultBump' + 'CreateMajorTag' + 'CreateMinorTag' + 'DatePrereleaseFormat' + 'IncrementalPrerelease' + 'UsePRTitleAsReleaseName' + 'UsePRBodyAsReleaseNotes' + 'UsePRTitleAsNotesHeading' + 'VersionPrefix' + 'WhatIf' + ) + $actionDefaults = [ordered]@{} + $actionInputs = @{} + foreach ($name in $settingNames) { + $actionDefaults[$name] = $actionDefinition.inputs[$name].default + $inputValue = [Environment]::GetEnvironmentVariable("PSMODULE_AUTO_RELEASE_INPUT_$name") + if ($null -ne $inputValue) { + $actionInputs[$name] = $inputValue + } } - $autoCleanup = ![string]::IsNullOrEmpty($configuration.AutoCleanup) ? $configuration.AutoCleanup -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup -eq 'true' - $defaultBump = if ($null -ne $configuration.DefaultBump) { - [string] $configuration.DefaultBump - } elseif ([string]::IsNullOrEmpty($env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump)) { - 'patch' + $configurationPath = [Environment]::GetEnvironmentVariable('PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile') + if ($null -eq $configurationPath) { + $configurationPath = $actionDefinition.inputs.ConfigurationFile.default + } + if ([string]::IsNullOrWhiteSpace($configurationPath)) { + throw 'ConfigurationFile must be a nonempty path.' + } + $configuration = @{} + if (-not (Test-Path -LiteralPath $configurationPath -PathType Leaf)) { + Write-Output "Configuration file not found at [$configurationPath]" } else { - $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump + Write-Output "Reading from configuration file [$configurationPath]" + $configuration = ConvertFrom-Yaml -Yaml (Get-Content -LiteralPath $configurationPath -Raw) + if ($null -eq $configuration) { + throw "Release configuration [$configurationPath] must be a mapping of supported settings." + } } - $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 - $incrementalPrerelease = ![string]::IsNullOrEmpty($configuration.IncrementalPrerelease) ? $configuration.IncrementalPrerelease -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_IncrementalPrerelease -eq 'true' - $usePRBodyAsReleaseNotes = ![string]::IsNullOrEmpty($configuration.UsePRBodyAsReleaseNotes) ? $configuration.UsePRBodyAsReleaseNotes -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_UsePRBodyAsReleaseNotes -eq 'true' - $usePRTitleAsReleaseName = ![string]::IsNullOrEmpty($configuration.UsePRTitleAsReleaseName) ? $configuration.UsePRTitleAsReleaseName -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsReleaseName -eq 'true' - $usePRTitleAsNotesHeading = ![string]::IsNullOrEmpty($configuration.UsePRTitleAsNotesHeading) ? $configuration.UsePRTitleAsNotesHeading -eq 'true' : $env:PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsNotesHeading -eq 'true' - $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 + + $settings = Resolve-ReleaseConfiguration -Defaults $actionDefaults -Inputs $actionInputs -Configuration $configuration + $autoCleanup = $settings.AutoCleanup + $defaultBump = $settings.DefaultBump + $createMajorTag = $settings.CreateMajorTag + $createMinorTag = $settings.CreateMinorTag + $datePrereleaseFormat = $settings.DatePrereleaseFormat + $incrementalPrerelease = $settings.IncrementalPrerelease + $usePRBodyAsReleaseNotes = $settings.UsePRBodyAsReleaseNotes + $usePRTitleAsReleaseName = $settings.UsePRTitleAsReleaseName + $usePRTitleAsNotesHeading = $settings.UsePRTitleAsNotesHeading + $versionPrefix = $settings.VersionPrefix + $whatIf = $settings.WhatIf Write-Output '-------------------------------------------------' Write-Output "Auto cleanup enabled: [$autoCleanup]" @@ -155,8 +184,7 @@ LogGroup 'Pull request - details' { } LogGroup 'Pull request - Labels' { - $labels = @() - $labels += $pull_request.labels.name + $labels = @($pull_request.labels.name | Where-Object { $null -ne $_ }) $labels | Format-List | Out-String } diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 40c6515..2b329aa 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -10,6 +10,197 @@ BeforeAll { Import-Module -Name $modulePath -Force } +Describe 'Resolve-ReleaseConfiguration' { + BeforeAll { + $resolveConfiguration = @{ + Defaults = [ordered]@{ + AutoCleanup = 'true' + DefaultBump = 'patch' + CreateMajorTag = 'true' + CreateMinorTag = 'true' + DatePrereleaseFormat = '' + IncrementalPrerelease = 'true' + UsePRTitleAsReleaseName = 'false' + UsePRBodyAsReleaseNotes = 'true' + UsePRTitleAsNotesHeading = 'true' + VersionPrefix = 'v' + WhatIf = 'false' + } + } + } + + It 'applies every documented controller default when settings are omitted' { + $result = Resolve-ReleaseConfiguration @resolveConfiguration + $expected = [ordered]@{ + AutoCleanup = $true + DefaultBump = 'patch' + CreateMajorTag = $true + CreateMinorTag = $true + DatePrereleaseFormat = '' + IncrementalPrerelease = $true + UsePRTitleAsReleaseName = $false + UsePRBodyAsReleaseNotes = $true + UsePRTitleAsNotesHeading = $true + VersionPrefix = 'v' + WhatIf = $false + } + + $result.PSObject.Properties.Name | Should -Be @($expected.Keys) + foreach ($name in $expected.Keys) { + $result.$name | Should -Be $expected[$name] -Because "[$name] has a documented default" + $result.$name.GetType() | Should -Be $expected[$name].GetType() + } + } + + It 'uses a caller DefaultBump when the file omits it' { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Inputs @{ DefaultBump = 'minor' } -Configuration @{} + $decision = Resolve-ReleaseDecision -Labels @() -DefaultBump $result.DefaultBump + + $decision.Bump | Should -BeExactly 'Minor' + $decision.DefaultBumpApplied | Should -BeTrue + } + + It 'lets file settings override caller inputs without mutating either mapping' { + $inputs = @{ DefaultBump = 'patch'; AutoCleanup = 'true'; VersionPrefix = 'v' } + $configuration = [ordered]@{ DefaultBump = 'major'; AutoCleanup = $false; VersionPrefix = '' } + + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Inputs $inputs -Configuration $configuration + $decision = Resolve-ReleaseDecision -Labels @() -DefaultBump $result.DefaultBump + + $decision.Bump | Should -BeExactly 'Major' + $result.AutoCleanup | Should -BeFalse + $result.VersionPrefix | Should -BeExactly '' + $inputs.DefaultBump | Should -BeExactly 'patch' + $inputs.AutoCleanup | Should -BeExactly 'true' + $configuration.DefaultBump | Should -BeExactly 'major' + $configuration.AutoCleanup | Should -BeFalse + $configuration.Count | Should -Be 3 + } + + It 'lets an owned bump override the effective configured default' { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ DefaultBump = 'major' } + $decision = Resolve-ReleaseDecision -Labels @('release:minor') -DefaultBump $result.DefaultBump + + $decision.Bump | Should -BeExactly 'Minor' + $decision.DefaultBumpApplied | Should -BeFalse + } + + It 'preserves an empty caller value for ' -ForEach @( + @{ Name = 'VersionPrefix' } + @{ Name = 'DatePrereleaseFormat' } + ) { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Inputs @{ $Name = '' } + + $result.$Name | Should -BeExactly '' + } + + It 'lets an empty file value override a nonempty input for ' -ForEach @( + @{ Name = 'VersionPrefix'; InputValue = 'v' } + @{ Name = 'DatePrereleaseFormat'; InputValue = 'yyyyMMddHHmm' } + ) { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Inputs @{ $Name = $InputValue } -Configuration @{ $Name = '' } + + $result.$Name | Should -BeExactly '' + } + + It 'accepts boolean values and case-insensitive true/false strings for every boolean setting' { + $names = @( + 'AutoCleanup' + 'CreateMajorTag' + 'CreateMinorTag' + 'IncrementalPrerelease' + 'UsePRTitleAsReleaseName' + 'UsePRBodyAsReleaseNotes' + 'UsePRTitleAsNotesHeading' + 'WhatIf' + ) + + foreach ($name in $names) { + foreach ($value in @($true, $false, 'true', 'false', 'TRUE', 'FALSE')) { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ $name = $value } + + $result.$name | Should -BeOfType ([bool]) + $result.$name | Should -Be ($value.ToString() -eq 'true') + } + } + } + + It 'rejects invalid boolean setting []' -ForEach @( + @{ Value = '' } + @{ Value = 'yes' } + @{ Value = ' true ' } + @{ Value = 1 } + @{ Value = $null } + @{ Value = @('true', 'false') } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ WhatIf = $Value } } | + Should -Throw '*WhatIf*true or false*' + } + + It 'rejects an explicit invalid DefaultBump [] instead of selecting patch' -ForEach @( + @{ Value = '' } + @{ Value = $null } + @{ Value = 'Patch' } + @{ Value = 'date' } + @{ Value = 'none' } + @{ Value = 1 } + @{ Value = @('patch', 'minor') } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ DefaultBump = $Value } } | + Should -Throw '*DefaultBump*' + } + + It 'rejects an invalid input that is not overridden by a valid file setting' { + { Resolve-ReleaseConfiguration @resolveConfiguration -Inputs @{ DefaultBump = 'invalid' } } | + Should -Throw '*DefaultBump*' + } + + It 'validates the effective file value rather than an overridden input' { + $parameters = @{ + Inputs = @{ DefaultBump = 'invalid' } + Configuration = @{ DefaultBump = 'minor' } + } + $result = Resolve-ReleaseConfiguration @resolveConfiguration @parameters + + $result.DefaultBump | Should -BeExactly 'minor' + } + + It 'rejects null and non-string text settings instead of coercing them' -ForEach @( + @{ Name = 'VersionPrefix'; Value = $null } + @{ Name = 'VersionPrefix'; Value = 1 } + @{ Name = 'DatePrereleaseFormat'; Value = $false } + @{ Name = 'DatePrereleaseFormat'; Value = @{ format = 'yyyyMMdd' } } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ $Name = $Value } } | + Should -Throw "*$Name*string*" + } + + It 'rejects the retired setting with migration guidance' -ForEach @( + @{ Name = 'AutoPatching' } + @{ Name = 'IgnoreLabels' } + @{ Name = 'MajorLabels' } + @{ Name = 'MinorLabels' } + @{ Name = 'PatchLabels' } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ $Name = 'legacy' } } | + Should -Throw "*$Name*removed*DefaultBump*release:*" + } + + It 'rejects an unknown setting instead of silently ignoring a typo' { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ DefaultBunp = 'minor' } } | + Should -Throw '*Unsupported release setting*DefaultBunp*' + } + + It 'rejects a configuration root that is not a mapping' -ForEach @( + @{ Configuration = 'DefaultBump: minor' } + @{ Configuration = @('patch', 'minor') } + @{ Configuration = $true } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration $Configuration } | + Should -Throw '*configuration must be a mapping*' + } +} + Describe 'Get-ReleaseLabelDefinition' { BeforeAll { $definitions = @(Get-ReleaseLabelDefinition) From a71f7d1082af9f46478bf496cb28469361ce3067 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 12:48:26 +0100 Subject: [PATCH 22/27] Exercise the release caller without repository mutations --- .github/workflows/Action-Test.yml | 3 + tests/Release-GHRepository.Tests.ps1 | 229 +++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/Release-GHRepository.Tests.ps1 diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index 89aeb86..e8239d7 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -37,6 +37,9 @@ jobs: with: Path: tests Version: 6.1.0 + Prescript: | + Install-PSResource -Name powershell-yaml -Version '[0.4.12]' -TrustRepository -Repository PSGallery + Install-PSResource -Name PSSemVer -Version '[1.1.10]' -TrustRepository -Repository PSGallery ActionTestDefault: name: Action-Test - [Default] diff --git a/tests/Release-GHRepository.Tests.ps1 b/tests/Release-GHRepository.Tests.ps1 new file mode 100644 index 0000000..7403676 --- /dev/null +++ b/tests/Release-GHRepository.Tests.ps1 @@ -0,0 +1,229 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in Pester setup and used by tests and mocks.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeAll { + Import-Module -Name GitHub, powershell-yaml, PSSemVer + $mainPath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\main.ps1' + $headSha = '0123456789012345678901234567890123456789' + $authoredBody = @' +This release preserves the source-bound adoption record. + +## Adopting this release + +1. Select the reviewed release. +2. Keep the caller's explicit settings. + + +
+Technical details + +The complete record remains part of the release notes. + +
+ +Resolved release decision: release:major, stable. +'@ +} + +Describe 'Release-GHRepository caller compatibility' { + BeforeEach { + $environmentNames = @( + 'GITHUB_REPOSITORY' + 'GITHUB_EVENT_PATH' + 'PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile' + 'PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup' + 'PSMODULE_AUTO_RELEASE_INPUT_DefaultBump' + 'PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag' + 'PSMODULE_AUTO_RELEASE_INPUT_CreateMinorTag' + 'PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat' + 'PSMODULE_AUTO_RELEASE_INPUT_IncrementalPrerelease' + 'PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsReleaseName' + 'PSMODULE_AUTO_RELEASE_INPUT_UsePRBodyAsReleaseNotes' + 'PSMODULE_AUTO_RELEASE_INPUT_UsePRTitleAsNotesHeading' + 'PSMODULE_AUTO_RELEASE_INPUT_VersionPrefix' + 'PSMODULE_AUTO_RELEASE_INPUT_WhatIf' + ) + $savedEnvironment = @{} + foreach ($name in $environmentNames) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) + if (Test-Path -LiteralPath "Env:\$name") { + Remove-Item -LiteralPath "Env:\$name" + } + } + + $env:GITHUB_REPOSITORY = 'example/repository' + $env:GITHUB_EVENT_PATH = Join-Path -Path $TestDrive -ChildPath 'event.json' + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = Join-Path -Path $TestDrive -ChildPath 'settings.yml' + $env:PSMODULE_AUTO_RELEASE_INPUT_WhatIf = 'true' + $githubEvent = @{ + action = 'opened' + pull_request = @{ + number = 42 + title = 'The complete authored record is preserved' + body = $authoredBody + merged = $false + state = 'open' + base = @{ ref = 'main' } + head = @{ ref = 'feature/caller'; sha = $headSha } + labels = @() + } + } + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + Mock Install-PSResource {} + Mock Write-GitHubNotice {} + Mock git { throw 'A caller compatibility test must never invoke git.' } + Mock gh { + $global:LASTEXITCODE = 0 + if ($args[0] -eq 'repo' -and $args[1] -eq 'view') { + '{"defaultBranchRef":{"name":"main"}}' + } elseif ($args[0] -eq 'release' -and $args[1] -eq 'list') { + '[{"tagName":"v2.0.3","isLatest":true,"isPrerelease":false}]' + } else { + throw "A caller compatibility test must never mutate GitHub: gh $($args -join ' ')" + } + } + } + + AfterEach { + foreach ($name in $environmentNames) { + if ($null -eq $savedEnvironment[$name]) { + if (Test-Path -LiteralPath "Env:\$name") { + Remove-Item -LiteralPath "Env:\$name" + } + } else { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name]) + } + } + } + + It 'uses action.yml defaults without relying on the composite wrapper to inject them' { + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump:\s+\[patch\]' + $output | Should -Match 'Auto cleanup enabled:\s+\[True\]' + $output | Should -Match 'Use PR body as release notes:\s+\[True\]' + $output | Should -Match 'gh release create v2\.0\.4 ' + $output.Contains($authoredBody) | Should -BeTrue + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'uses a caller minor default for an unlabeled pull request' { + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump = 'minor' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'gh release create v2\.1\.0 ' + Should -Invoke gh -Times 2 -Exactly + } + + It 'uses file overrides, including empty text and false, with the real YAML parser' { + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump = 'major' + $env:PSMODULE_AUTO_RELEASE_INPUT_DatePrereleaseFormat = 'yyyyMMddHHmm' + @' +DefaultBump: minor +AutoCleanup: false +DatePrereleaseFormat: '' +VersionPrefix: '' +'@ | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'Auto cleanup enabled:\s+\[False\]' + $output | Should -Match 'Date-based prerelease format:\s+\[\]' + $output | Should -Match 'gh release create 2\.1\.0 ' + Should -Invoke gh -Times 2 -Exactly + } + + It 'lets an explicit owned major override a file minor default and preserves the complete record' { + 'DefaultBump: minor' | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $githubEvent.pull_request.labels = @(@{ name = 'release:major' }, @{ name = 'dependencies' }) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump applied:\s+\[False\]' + $output | Should -Match 'gh release create v3\.0\.0 ' + $output.Contains($authoredBody) | Should -BeTrue + $output | Should -Match '# The complete authored record is preserved \(#42\)' + Should -Invoke gh -Times 2 -Exactly + } + + It 'ignores dependency, retired, and bare labels when using the caller default' { + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump = 'minor' + $githubEvent.pull_request.labels = @( + @{ name = 'major' } + @{ name = 'dependencies' } + @{ name = 'release:pre-release' } + ) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump applied:\s+\[True\]' + $output | Should -Match 'Create a prerelease:\s+\[False\]' + $output | Should -Match 'gh release create v2\.1\.0 ' + } + + It 'validates a merged skipped pull request successfully without planning publication' { + $githubEvent.pull_request.labels = @(@{ name = 'release:skip' }) + $githubEvent.pull_request.merged = $true + $githubEvent.pull_request.state = 'closed' + $githubEvent.action = 'closed' + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Skip release:\s+\[True\]' + $output | Should -Match 'Skipping release creation\.' + $output | Should -Not -Match 'gh release create|git push|git tag' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke Write-GitHubNotice -Times 0 -Exactly + } + + It 'targets a prerelease at the exact pull-request head without executing publication commands' { + $githubEvent.pull_request.labels = @(@{ name = 'release:prerelease' }, @{ name = 'release:minor' }) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'gh release create v2\.1\.0-featurecaller\.1 ' + $output | Should -Match "--target $headSha --prerelease" + $output | Should -Match 'WhatIf: gh pr comment 42 ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects conflicting owned labels instead of falling back to the default' { + $githubEvent.pull_request.labels = @(@{ name = 'release:patch' }, @{ name = 'release:major' }) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + { & $mainPath } | Should -Throw '*Conflicting release bump labels*' + Should -Invoke gh -Times 1 -Exactly + } + + It 'rejects invalid effective file settings before calling GitHub' -ForEach @( + @{ Yaml = 'DefaultBump: null'; Message = '*DefaultBump*' } + @{ Yaml = 'DefaultBump: date'; Message = '*Invalid DefaultBump*' } + @{ Yaml = 'WhatIf: invalid'; Message = '*WhatIf*true or false*' } + @{ Yaml = 'AutoPatching: false'; Message = '*AutoPatching*removed*' } + @{ Yaml = 'DefaultBunp: minor'; Message = '*Unsupported release setting*' } + @{ Yaml = '- patch'; Message = '*configuration must be a mapping*' } + @{ Yaml = ''; Message = '*configuration*must be a mapping*' } + ) { + $Yaml | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + + { & $mainPath } | Should -Throw $Message + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } +} From 8fac168e34287a22ee080855450c80f57e601554 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:09:50 +0100 Subject: [PATCH 23/27] Add explicit caller overrides and release-decision provenance --- .github/workflows/Action-Test.yml | 2 + README.md | 46 +++-- action.yml | 17 +- src/Release-GHRepository.Helpers.psm1 | 173 ++++++++++++++----- src/main.ps1 | 26 ++- tests/Release-GHRepository.Helpers.Tests.ps1 | 104 ++++++++++- tests/Release-GHRepository.Tests.ps1 | 69 +++++++- 7 files changed, 368 insertions(+), 69 deletions(-) diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index e8239d7..20a5600 100644 --- a/.github/workflows/Action-Test.yml +++ b/.github/workflows/Action-Test.yml @@ -57,3 +57,5 @@ jobs: uses: ./ with: WhatIf: true + CallerOverrides: | + WhatIf: true diff --git a/README.md b/README.md index 9d72072..b2f6f3d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Create GitHub releases from owned pull-request labels and a configurable default bump. +This action is for repository and workflow maintainers who publish GitHub releases through the `action.yml` input interface and version-controlled release settings. + ## Release decision Release-GHRepository owns and provisions five labels: @@ -71,9 +73,11 @@ jobs: persist-credentials: false - name: Release repository - uses: PSModule/Release-GHRepository@v3 + uses: PSModule/Release-GHRepository@ ``` +Select the full immutable commit SHA of an authorized, published release that supports this contract. An open pull request's head is a development candidate, not a production dependency. A controlled major tag is an option only when the producer's release automation exclusively maintains it within its compatible stable line. + 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 @@ -81,7 +85,8 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch | 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 | +| `CallerOverrides` | Supply a YAML or JSON mapping of explicit invocation overrides. These override repository settings. | `{}` | false | +| `ConfigurationFile` | Read repository settings from this file. File values override scalar action inputs, but not `CallerOverrides`. | `.github\auto-release.yml` | 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 | @@ -104,6 +109,12 @@ Use the `release:prerelease` label to select repository prerelease behavior. The The default configuration file is `.github\auto-release.yml`. Change its path with `ConfigurationFile`. +Settings resolve in this order: defaults declared in `action.yml`, scalar action inputs, repository settings, then explicit `CallerOverrides`. Scalar inputs retain the existing file-over-input behavior because GitHub injects their defaults even when callers omit them. Use `CallerOverrides` for an invocation-specific override; its omitted `{}` mapping inherits repository settings without masking them. + +An explicit `false` or empty string is an override, not an omitted setting. Booleans accept YAML `true` and `false` or case-insensitive strings containing exactly `true` or `false`. Text settings require strings; only `VersionPrefix` and `DatePrereleaseFormat` accept empty strings. Every supplied layer is validated before merging, so a valid override cannot hide an invalid lower-priority value. + +The file and `CallerOverrides` must contain mappings. They accept the release settings from `AutoCleanup` through `WhatIf` in the input table, excluding `CallerOverrides` and `ConfigurationFile` themselves. The dependency/runtime inputs (`Debug`, `Verbose`, `Version`, `Prerelease`, and `WorkingDirectory`) are supplied separately by the caller. Unknown keys, removed keys, null values, and invalid values fail before label provisioning. + ```yaml DatePrereleaseFormat: 'yyyyMMddHHmm' DefaultBump: patch @@ -111,17 +122,26 @@ IncrementalPrerelease: false VersionPrefix: '' ``` -## Migrate from v2 +For an invocation-specific minor bump, even when the repository configures major: + +```yaml +with: + CallerOverrides: | + DefaultBump: minor + AutoCleanup: false + VersionPrefix: '' +``` + +JSON mappings are supported too, such as `CallerOverrides: '{"DefaultBump":"minor"}'`. An explicit owned bump label still overrides the valid effective `DefaultBump`. The action reports whether the bump came from an owned label, explicit caller override, repository setting, scalar base input, or shared default. + +`DatePrereleaseFormat` formats only the existing prerelease suffix. `DefaultBump` does not support date versioning or a disabled automatic bump. Use `release:skip` when validation must succeed without publication. + +## Release records + +The release-bound pull request's title and complete body contain the incremental adoption instructions, changed consumer interfaces, verification evidence, and resolved release decision. Compose those records across the release range you adopt; repository guides do not replace them. -`v3` is a breaking release. Existing `v2` references and behavior remain unchanged. +The [owned-label release record](https://github.com/PSModule/Release-GHRepository/pull/109) contains the caller migration and publisher bootstrap requirements for this contract. -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. 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. +## Contributing -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. +Follow the [MSX coding standards](https://msxorg.github.io/docs/Coding-Standards/) and [contribution workflow](https://msxorg.github.io/docs/Ways-of-Working/Contribution-Workflow/). Keep release-decision changes separate from changes to stable-publication authority and recovery. diff --git a/action.yml b/action.yml index d2189ef..4354c9d 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: Release GHRepository (by PSModule) -description: Automatically creates releases based on pull requests and labels. +description: Creates GitHub releases from owned pull-request labels and effective release settings. author: PSModule branding: icon: upload-cloud @@ -7,11 +7,19 @@ branding: inputs: AutoCleanup: - description: Control whether to automatically delete the prerelease tags after the stable release is created. + description: Delete pull-request prereleases and their tags after the pull request closes. required: false default: 'true' + CallerOverrides: + description: >- + A YAML or JSON mapping of explicit release-setting overrides. Overrides repository settings + without confusing omitted inputs with action defaults. + required: false + default: '{}' ConfigurationFile: - description: The path to the configuration file. Settings in the configuration file take precedence over the action inputs. + description: >- + The repository settings file. File values override scalar action inputs; + explicit CallerOverrides take precedence over file values. required: false default: .github\auto-release.yml DefaultBump: @@ -51,7 +59,7 @@ inputs: required: false default: v WhatIf: - description: If specified, the action will only log the changes it would make, but will not actually create or delete any releases or tags. + description: Log planned label, release, tag, and comment changes without mutating repository state. required: false default: 'false' Debug: @@ -81,6 +89,7 @@ runs: uses: PSModule/GitHub-Script@8083ec1f733f00357ee4d0db0c6056686e483bc0 # v1.9.0 env: PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup: ${{ inputs.AutoCleanup }} + PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides: ${{ inputs.CallerOverrides }} PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile: ${{ inputs.ConfigurationFile }} PSMODULE_AUTO_RELEASE_INPUT_DefaultBump: ${{ inputs.DefaultBump }} PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag: ${{ inputs.CreateMajorTag }} diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 5d7c0f8..5a3539c 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -1,21 +1,101 @@ $ErrorActionPreference = 'Stop' +function ConvertTo-ReleaseSettingValue { + <# + .SYNOPSIS + Validate and convert one supplied release-setting value. + + .DESCRIPTION + Preserve valid text values, normalize booleans, and validate default + bumps and prerelease date formats before settings are combined. + + .EXAMPLE + ConvertTo-ReleaseSettingValue -Name 'AutoCleanup' -Value 'false' + + Return the Boolean false value. + + .INPUTS + None + + You can't pipe objects to ConvertTo-ReleaseSettingValue. + + .OUTPUTS + System.Boolean + + A validated Boolean setting. + + .OUTPUTS + System.String + + A validated text setting. + #> + [OutputType([bool], [string])] + [CmdletBinding()] + param( + # The supported release-setting name. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Name, + + # The supplied value, including null so invalid values can be diagnosed. + [Parameter(Mandatory)] + [AllowNull()] + [AllowEmptyString()] + [object] $Value + ) + + $booleanSettings = @( + 'AutoCleanup' + 'CreateMajorTag' + 'CreateMinorTag' + 'IncrementalPrerelease' + 'UsePRTitleAsReleaseName' + 'UsePRBodyAsReleaseNotes' + 'UsePRTitleAsNotesHeading' + 'WhatIf' + ) + if ($booleanSettings -contains $Name) { + if ($Value -is [bool]) { + $Value + } elseif ($Value -is [string] -and @('true', 'false') -contains $Value) { + $Value -eq 'true' + } else { + throw "Invalid release setting [$Name]. Use true or false." + } + return + } + if ($Value -isnot [string]) { + throw "Invalid release setting [$Name]. Use a string value." + } + if ($Name -eq 'DefaultBump') { + $null = ConvertTo-ReleaseBump -DefaultBump $Value + } + if ($Name -eq 'DatePrereleaseFormat' -and -not [string]::IsNullOrEmpty($Value)) { + try { + $null = [datetime]::new(2000, 1, 1).ToString($Value, [Globalization.CultureInfo]::InvariantCulture) + } catch [FormatException] { + throw 'Invalid DatePrereleaseFormat. Use a valid .NET date and time format string.' + } + } + $Value +} + function Resolve-ReleaseConfiguration { <# .SYNOPSIS Resolve validated release settings from interface defaults and overrides. .DESCRIPTION - Apply action inputs over the defaults declared in action.yml, then apply - configuration-file settings over those inputs. Preserve explicit false - and empty string values. Reject invalid and retired settings before - repository state can change. + Apply scalar action inputs over defaults declared in action.yml, then + repository settings, then explicit CallerOverrides. Validate every + supplied layer so invalid values cannot be masked. Preserve false and + empty text overrides and record the effective default-bump source. .EXAMPLE $parameters = @{ - Defaults = $actionDefaults - Inputs = @{ DefaultBump = 'patch' } - Configuration = @{ DefaultBump = 'minor' } + Defaults = $actionDefaults + Configuration = @{ DefaultBump = 'major' } + CallerOverrides = @{ DefaultBump = 'minor' } } Resolve-ReleaseConfiguration @parameters @@ -46,65 +126,54 @@ function Resolve-ReleaseConfiguration { # The parsed configuration file, which must be a mapping when supplied. [Parameter()] [AllowNull()] - [object] $Configuration + [object] $Configuration, + + # Explicit invocation overrides, distinct from injected scalar defaults. + [Parameter()] + [AllowNull()] + [AllowEmptyString()] + [object] $CallerOverrides ) if ($null -ne $Configuration -and $Configuration -isnot [System.Collections.IDictionary]) { throw 'Release configuration must be a mapping of supported settings.' } + if ($PSBoundParameters.ContainsKey('CallerOverrides') -and $CallerOverrides -isnot [System.Collections.IDictionary]) { + throw 'CallerOverrides must be a mapping of supported release settings.' + } $retiredSettings = @('AutoPatching', 'IgnoreLabels', 'MajorLabels', 'MinorLabels', 'PatchLabels') - foreach ($mapping in @($Inputs, $Configuration)) { - if ($null -eq $mapping) { + $layers = @( + [PSCustomObject]@{ Source = 'SharedDefault'; Values = $Defaults } + [PSCustomObject]@{ Source = 'ActionInput'; Values = $Inputs } + [PSCustomObject]@{ Source = 'RepositorySetting'; Values = $Configuration } + [PSCustomObject]@{ Source = 'CallerOverride'; Values = $CallerOverrides } + ) + $settings = [ordered]@{} + $defaultBumpSource = 'SharedDefault' + foreach ($layer in $layers) { + if ($null -eq $layer.Values) { continue } - foreach ($name in $mapping.Keys) { + foreach ($name in $layer.Values.Keys) { if ($retiredSettings -contains $name) { throw "Release setting [$name] was removed. Use DefaultBump and the owned release: labels." } if ($Defaults.Keys -notcontains $name) { throw "Unsupported release setting [$name]. Use the settings documented in action.yml." } - } - } - - $booleanSettings = @( - 'AutoCleanup' - 'CreateMajorTag' - 'CreateMinorTag' - 'IncrementalPrerelease' - 'UsePRTitleAsReleaseName' - 'UsePRBodyAsReleaseNotes' - 'UsePRTitleAsNotesHeading' - 'WhatIf' - ) - $settings = [ordered]@{} - foreach ($name in $Defaults.Keys) { - $value = $Defaults[$name] - if ($null -ne $Inputs -and $Inputs.Contains($name)) { - $value = $Inputs[$name] - } - if ($null -ne $Configuration -and $Configuration.Contains($name)) { - $value = $Configuration[$name] - } - - if ($booleanSettings -contains $name) { - if ($value -is [bool]) { - $settings[$name] = $value - } elseif ($value -is [string] -and @('true', 'false') -contains $value) { - $settings[$name] = $value -eq 'true' - } else { - throw "Invalid release setting [$name]. Use true or false." - } - } else { - if ($value -isnot [string]) { - throw "Invalid release setting [$name]. Use a string value." + $settings[$name] = ConvertTo-ReleaseSettingValue -Name $name -Value $layer.Values[$name] + if ($name -eq 'DefaultBump') { + $defaultBumpSource = $layer.Source + if ($layer.Source -eq 'ActionInput' -and $layer.Values[$name] -ceq $Defaults[$name]) { + $defaultBumpSource = 'SharedDefault' + } } - $settings[$name] = $value } } $null = ConvertTo-ReleaseBump -DefaultBump $settings.DefaultBump + $settings.DefaultBumpSource = $defaultBumpSource [PSCustomObject] $settings } @@ -307,7 +376,12 @@ function Resolve-ReleaseDecision { [Parameter()] [AllowNull()] [AllowEmptyString()] - [string] $DefaultBump = 'patch' + [string] $DefaultBump = 'patch', + + # The validated settings layer that supplies the effective default. + [Parameter()] + [ValidateSet('SharedDefault', 'ActionInput', 'RepositorySetting', 'CallerOverride')] + [string] $DefaultBumpSource = 'SharedDefault' ) $resolvedDefaultBump = ConvertTo-ReleaseBump -DefaultBump $DefaultBump @@ -341,6 +415,7 @@ function Resolve-ReleaseDecision { Prerelease = $false Skip = $true DefaultBumpApplied = $false + BumpSource = 'release:skip' } return } @@ -361,6 +436,7 @@ function Resolve-ReleaseDecision { Prerelease = $hasPrerelease Skip = $false DefaultBumpApplied = $defaultBumpApplied + BumpSource = if ($defaultBumpApplied) { $DefaultBumpSource } else { $bumpLabels[0] } } } @@ -412,6 +488,9 @@ function Resolve-PullRequestReleaseContext { if ([string]::IsNullOrWhiteSpace($headSha)) { throw 'Pull request head SHA is required.' } + if ($headSha -notmatch '\A[0-9a-fA-F]{40}\z') { + throw 'Pull request head SHA must be a 40-character hexadecimal commit.' + } $prereleaseName = $headRef -replace '[^a-zA-Z0-9]' if ([string]::IsNullOrWhiteSpace($prereleaseName)) { diff --git a/src/main.ps1 b/src/main.ps1 index 42eaa3f..d70dd9b 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -80,9 +80,24 @@ LogGroup 'Set configuration' { } } - $settings = Resolve-ReleaseConfiguration -Defaults $actionDefaults -Inputs $actionInputs -Configuration $configuration + $callerOverridesYaml = [Environment]::GetEnvironmentVariable('PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides') + if ($null -eq $callerOverridesYaml) { + $callerOverridesYaml = $actionDefinition.inputs.CallerOverrides.default + } + $callerOverrides = ConvertFrom-Yaml -Yaml $callerOverridesYaml + if ($callerOverrides -isnot [System.Collections.IDictionary]) { + throw 'CallerOverrides must be a mapping of supported release settings.' + } + $configurationParameters = @{ + Defaults = $actionDefaults + Inputs = $actionInputs + Configuration = $configuration + CallerOverrides = $callerOverrides + } + $settings = Resolve-ReleaseConfiguration @configurationParameters $autoCleanup = $settings.AutoCleanup $defaultBump = $settings.DefaultBump + $defaultBumpSource = $settings.DefaultBumpSource $createMajorTag = $settings.CreateMajorTag $createMinorTag = $settings.CreateMinorTag $datePrereleaseFormat = $settings.DatePrereleaseFormat @@ -96,6 +111,7 @@ LogGroup 'Set configuration' { Write-Output '-------------------------------------------------' Write-Output "Auto cleanup enabled: [$autoCleanup]" Write-Output "Default bump: [$defaultBump]" + Write-Output "Default bump source: [$defaultBumpSource]" Write-Output "Create major tag enabled: [$createMajorTag]" Write-Output "Create minor tag enabled: [$createMinorTag]" Write-Output "Date-based prerelease format: [$datePrereleaseFormat]" @@ -188,7 +204,12 @@ LogGroup 'Pull request - Labels' { $labels | Format-List | Out-String } -$releaseDecision = Resolve-ReleaseDecision -Labels $labels -DefaultBump $defaultBump +$decisionParameters = @{ + Labels = $labels + DefaultBump = $defaultBump + DefaultBumpSource = $defaultBumpSource +} +$releaseDecision = Resolve-ReleaseDecision @decisionParameters $createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged @@ -201,6 +222,7 @@ $patchRelease = $releaseDecision.Bump -eq 'Patch' Write-Output '-------------------------------------------------' Write-Output "Default bump applied: [$($releaseDecision.DefaultBumpApplied)]" +Write-Output "Bump source: [$($releaseDecision.BumpSource)]" Write-Output "Skip release: [$($releaseDecision.Skip)]" Write-Output "Create a release: [$createRelease]" Write-Output "Create a prerelease: [$createPrerelease]" diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 2b329aa..6de0cc9 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -45,7 +45,8 @@ Describe 'Resolve-ReleaseConfiguration' { WhatIf = $false } - $result.PSObject.Properties.Name | Should -Be @($expected.Keys) + $result.PSObject.Properties.Name | Should -Be (@($expected.Keys) + 'DefaultBumpSource') + $result.DefaultBumpSource | Should -BeExactly 'SharedDefault' foreach ($name in $expected.Keys) { $result.$name | Should -Be $expected[$name] -Because "[$name] has a documented default" $result.$name.GetType() | Should -Be $expected[$name].GetType() @@ -155,14 +156,91 @@ Describe 'Resolve-ReleaseConfiguration' { Should -Throw '*DefaultBump*' } - It 'validates the effective file value rather than an overridden input' { + It 'rejects an invalid input even when a valid file value overrides it' { $parameters = @{ Inputs = @{ DefaultBump = 'invalid' } Configuration = @{ DefaultBump = 'minor' } } + { Resolve-ReleaseConfiguration @resolveConfiguration @parameters } | + Should -Throw '*DefaultBump*' + } + + It 'lets explicit caller overrides take precedence over repository settings' { + $parameters = @{ + Inputs = @{ DefaultBump = 'patch' } + Configuration = @{ DefaultBump = 'major' } + CallerOverrides = @{ DefaultBump = 'minor' } + } + $result = Resolve-ReleaseConfiguration @resolveConfiguration @parameters $result.DefaultBump | Should -BeExactly 'minor' + $result.DefaultBumpSource | Should -BeExactly 'CallerOverride' + } + + It 'does not let injected scalar defaults mask repository settings when overrides are empty' { + $parameters = @{ + Inputs = @{ DefaultBump = 'patch' } + Configuration = @{ DefaultBump = 'major' } + CallerOverrides = @{} + } + + $result = Resolve-ReleaseConfiguration @resolveConfiguration @parameters + + $result.DefaultBump | Should -BeExactly 'major' + $result.DefaultBumpSource | Should -BeExactly 'RepositorySetting' + } + + It 'records a nondefault scalar input as a base input rather than an explicit caller override' { + $result = Resolve-ReleaseConfiguration @resolveConfiguration -Inputs @{ DefaultBump = 'minor' } + + $result.DefaultBump | Should -BeExactly 'minor' + $result.DefaultBumpSource | Should -BeExactly 'ActionInput' + } + + It 'preserves explicit false and empty text caller overrides' { + $parameters = @{ + Configuration = @{ AutoCleanup = $true; VersionPrefix = 'v'; DatePrereleaseFormat = 'yyyyMMdd' } + CallerOverrides = @{ AutoCleanup = $false; VersionPrefix = ''; DatePrereleaseFormat = '' } + } + + $result = Resolve-ReleaseConfiguration @resolveConfiguration @parameters + + $result.AutoCleanup | Should -BeFalse + $result.VersionPrefix | Should -BeExactly '' + $result.DatePrereleaseFormat | Should -BeExactly '' + } + + It 'rejects an invalid repository value even when the caller supplies a valid override' { + $parameters = @{ + Configuration = @{ DefaultBump = 'invalid' } + CallerOverrides = @{ DefaultBump = 'patch' } + } + + { Resolve-ReleaseConfiguration @resolveConfiguration @parameters } | + Should -Throw '*DefaultBump*' + } + + It 'rejects caller override settings []' -ForEach @( + @{ Name = 'DefaultBump'; Value = ''; Message = '*DefaultBump*' } + @{ Name = 'DefaultBump'; Value = $null; Message = '*DefaultBump*' } + @{ Name = 'DefaultBump'; Value = 'date'; Message = '*DefaultBump*' } + @{ Name = 'AutoCleanup'; Value = 'invalid'; Message = '*AutoCleanup*true or false*' } + @{ Name = 'VersionPrefix'; Value = $null; Message = '*VersionPrefix*string*' } + @{ Name = 'AutoPatching'; Value = $true; Message = '*AutoPatching*removed*' } + @{ Name = 'DefaultBunp'; Value = 'minor'; Message = '*Unsupported release setting*' } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -CallerOverrides @{ $Name = $Value } } | + Should -Throw $Message + } + + It 'rejects a supplied caller override root that is not a mapping' -ForEach @( + @{ Overrides = $null } + @{ Overrides = '' } + @{ Overrides = @('patch', 'minor') } + ) { + { Resolve-ReleaseConfiguration @resolveConfiguration -CallerOverrides $Overrides } | + Should -Throw '*CallerOverrides must be a mapping*' } It 'rejects null and non-string text settings instead of coercing them' -ForEach @( @@ -175,6 +253,11 @@ Describe 'Resolve-ReleaseConfiguration' { Should -Throw "*$Name*string*" } + It 'rejects an invalid prerelease date format before publication is considered' { + { Resolve-ReleaseConfiguration @resolveConfiguration -Configuration @{ DatePrereleaseFormat = '%' } } | + Should -Throw '*DatePrereleaseFormat*format*' + } + It 'rejects the retired setting with migration guidance' -ForEach @( @{ Name = 'AutoPatching' } @{ Name = 'IgnoreLabels' } @@ -661,6 +744,23 @@ Describe 'Resolve-ReleaseDecision' { Should -Throw '*head SHA is required*' } + It 'rejects a mutable or malformed head SHA []' -ForEach @( + @{ HeadSha = 'main' } + @{ HeadSha = 'abcdef' } + @{ HeadSha = '012345678901234567890123456789012345678g' } + @{ HeadSha = "0123456789012345678901234567890123456789`n" } + ) { + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = 'feature/invalid-sha' + sha = $HeadSha + } + } + + { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | + Should -Throw '*head SHA must be a 40-character hexadecimal commit*' + } + It 'rejects a head ref that has no valid prerelease identifier' { $pullRequest = [PSCustomObject]@{ head = [PSCustomObject]@{ diff --git a/tests/Release-GHRepository.Tests.ps1 b/tests/Release-GHRepository.Tests.ps1 index 7403676..10cdd69 100644 --- a/tests/Release-GHRepository.Tests.ps1 +++ b/tests/Release-GHRepository.Tests.ps1 @@ -37,6 +37,7 @@ Describe 'Release-GHRepository caller compatibility' { 'GITHUB_REPOSITORY' 'GITHUB_EVENT_PATH' 'PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile' + 'PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides' 'PSMODULE_AUTO_RELEASE_INPUT_AutoCleanup' 'PSMODULE_AUTO_RELEASE_INPUT_DefaultBump' 'PSMODULE_AUTO_RELEASE_INPUT_CreateMajorTag' @@ -59,7 +60,8 @@ Describe 'Release-GHRepository caller compatibility' { $env:GITHUB_REPOSITORY = 'example/repository' $env:GITHUB_EVENT_PATH = Join-Path -Path $TestDrive -ChildPath 'event.json' - $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = Join-Path -Path $TestDrive -ChildPath 'settings.yml' + $settingsFileName = 'settings-{0}.yml' -f [guid]::NewGuid().ToString('N') + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = Join-Path -Path $TestDrive -ChildPath $settingsFileName $env:PSMODULE_AUTO_RELEASE_INPUT_WhatIf = 'true' $githubEvent = @{ action = 'opened' @@ -174,6 +176,70 @@ VersionPrefix: '' $output | Should -Match 'gh release create v2\.1\.0 ' } + It 'applies typed YAML caller overrides without changing the scalar input/file precedence' { + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump = 'patch' + 'DefaultBump: major' | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = 'DefaultBump: minor' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'Default bump source:\s+\[CallerOverride\]' + $output | Should -Match 'Bump source:\s+\[CallerOverride\]' + $output | Should -Match 'gh release create v2\.1\.0 ' + Should -Invoke gh -Times 2 -Exactly + } + + It 'accepts JSON caller overrides including false and empty strings' { + @' +DefaultBump: major +AutoCleanup: true +VersionPrefix: v +'@ | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = '{"DefaultBump":"patch","AutoCleanup":false,"VersionPrefix":""}' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Default bump:\s+\[patch\]' + $output | Should -Match 'Auto cleanup enabled:\s+\[False\]' + $output | Should -Match 'gh release create 2\.0\.4 ' + Should -Invoke gh -Times 2 -Exactly + } + + It 'does not mask an invalid file value with a valid explicit caller override' { + 'DefaultBump: invalid' | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = 'DefaultBump: patch' + + { & $mainPath } | Should -Throw '*DefaultBump*' + Should -Invoke gh -Times 0 -Exactly + } + + It 'keeps validation read-only when explicit WhatIf overrides a false repository setting' { + 'WhatIf: false' | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = 'WhatIf: true' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'What if mode:\s+\[True\]' + $output | Should -Match 'WhatIf: gh label create ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects invalid caller overrides before contacting GitHub' -ForEach @( + @{ Overrides = ''; Message = '*CallerOverrides must be a mapping*' } + @{ Overrides = 'null'; Message = '*CallerOverrides must be a mapping*' } + @{ Overrides = '- patch'; Message = '*CallerOverrides must be a mapping*' } + @{ Overrides = 'DefaultBump: date'; Message = '*DefaultBump*' } + @{ Overrides = 'DefaultBump: null'; Message = '*DefaultBump*' } + @{ Overrides = 'WhatIf: invalid'; Message = '*WhatIf*true or false*' } + ) { + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = $Overrides + + { & $mainPath } | Should -Throw $Message + Should -Invoke gh -Times 0 -Exactly + } + It 'validates a merged skipped pull request successfully without planning publication' { $githubEvent.pull_request.labels = @(@{ name = 'release:skip' }) $githubEvent.pull_request.merged = $true @@ -215,6 +281,7 @@ VersionPrefix: '' @{ Yaml = 'DefaultBump: null'; Message = '*DefaultBump*' } @{ Yaml = 'DefaultBump: date'; Message = '*Invalid DefaultBump*' } @{ Yaml = 'WhatIf: invalid'; Message = '*WhatIf*true or false*' } + @{ Yaml = "DatePrereleaseFormat: '%'"; Message = '*DatePrereleaseFormat*format*' } @{ Yaml = 'AutoPatching: false'; Message = '*AutoPatching*removed*' } @{ Yaml = 'DefaultBunp: minor'; Message = '*Unsupported release setting*' } @{ Yaml = '- patch'; Message = '*configuration must be a mapping*' } From 89b776689d3de6815950f47cedb20d0086bc95c8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:29:31 +0100 Subject: [PATCH 24/27] Preserve valid release contexts and queue stable publication --- .github/workflows/Release.yml | 4 +-- README.md | 3 +- src/Release-GHRepository.Helpers.psm1 | 8 +++-- src/main.ps1 | 32 ++++++++++++-------- tests/Release-GHRepository.Helpers.Tests.ps1 | 15 +++++++++ tests/Release-GHRepository.Tests.ps1 | 31 +++++++++++++++++++ 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index b927465..9ce46f0 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -15,8 +15,8 @@ on: - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.event.pull_request.merged && github.event.pull_request.base.ref || github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write # Required to create releases diff --git a/README.md b/README.md index b2f6f3d..1518ef2 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ on: - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.merged && github.event.pull_request.base.ref || github.event.pull_request.number || github.ref }} + cancel-in-progress: false permissions: contents: write # Required to create releases and tags diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 5a3539c..81fa389 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -476,7 +476,11 @@ function Resolve-PullRequestReleaseContext { param( # The pull request from the GitHub event payload. [Parameter(Mandatory)] - [PSCustomObject] $PullRequest + [PSCustomObject] $PullRequest, + + # Permit non-prerelease contexts whose branch cannot name a prerelease. + [Parameter()] + [switch] $AllowEmptyPrereleaseName ) $headRef = [string] $PullRequest.head.ref @@ -493,7 +497,7 @@ function Resolve-PullRequestReleaseContext { } $prereleaseName = $headRef -replace '[^a-zA-Z0-9]' - if ([string]::IsNullOrWhiteSpace($prereleaseName)) { + if ([string]::IsNullOrWhiteSpace($prereleaseName) -and -not $AllowEmptyPrereleaseName) { throw "Pull request head ref [$headRef] does not contain a valid prerelease identifier." } diff --git a/src/main.ps1 b/src/main.ps1 index d70dd9b..012432b 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -178,7 +178,18 @@ $actionType = $githubEvent.action $isMerged = ($pull_request.merged).ToString() -eq 'True' $prIsClosed = $pull_request.state -eq 'closed' $prBaseRef = $pull_request.base.ref -$pullRequestReleaseContext = Resolve-PullRequestReleaseContext -PullRequest $pull_request +$labels = @($pull_request.labels.name | Where-Object { $null -ne $_ }) +$decisionParameters = @{ + Labels = $labels + DefaultBump = $defaultBump + DefaultBumpSource = $defaultBumpSource +} +$releaseDecision = Resolve-ReleaseDecision @decisionParameters +$contextParameters = @{ + PullRequest = $pull_request + AllowEmptyPrereleaseName = (-not $releaseDecision.Prerelease) +} +$pullRequestReleaseContext = Resolve-PullRequestReleaseContext @contextParameters $prHeadRef = $pullRequestReleaseContext.HeadRef $prereleaseTarget = $pullRequestReleaseContext.PrereleaseTarget $targetIsDefaultBranch = $pull_request.base.ref -eq $defaultBranchName @@ -200,17 +211,9 @@ LogGroup 'Pull request - details' { } LogGroup 'Pull request - Labels' { - $labels = @($pull_request.labels.name | Where-Object { $null -ne $_ }) $labels | Format-List | Out-String } -$decisionParameters = @{ - Labels = $labels - DefaultBump = $defaultBump - DefaultBumpSource = $defaultBumpSource -} -$releaseDecision = Resolve-ReleaseDecision @decisionParameters - $createRelease = $isMerged -and $targetIsDefaultBranch -and -not $releaseDecision.Skip $closedPullRequest = $prIsClosed -and -not $isMerged $createPrerelease = Test-PrereleaseCreation -ReleaseDecision $releaseDecision -PullRequestClosed:$prIsClosed @@ -441,9 +444,14 @@ if (-not $releaseDecision.Skip -and ($createPrerelease -or $createRelease -or $w } LogGroup 'List prereleases using the same name' { - $prereleasesToCleanup = @( - Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName $prereleaseName - ) + $prereleasesToCleanup = @() + if ([string]::IsNullOrEmpty($prereleaseName)) { + Write-Output 'This branch has no prerelease identifier; cleanup has no candidates.' + } else { + $prereleasesToCleanup = @( + Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName $prereleaseName + ) + } $prereleasesToCleanup | Select-Object -Property name, publishedAt, isPrerelease, isLatest | Format-Table | Out-String } diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 6de0cc9..8c55c14 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -772,6 +772,21 @@ Describe 'Resolve-ReleaseDecision' { { Resolve-PullRequestReleaseContext -PullRequest $pullRequest } | Should -Throw '*does not contain a valid prerelease identifier*' } + + It 'allows a valid non-prerelease branch without an ASCII prerelease identifier' { + $headRef = ([char] 0x2603).ToString() + $pullRequest = [PSCustomObject]@{ + head = [PSCustomObject]@{ + ref = $headRef + sha = '0123456789012345678901234567890123456789' + } + } + + $result = Resolve-PullRequestReleaseContext -PullRequest $pullRequest -AllowEmptyPrereleaseName + + $result.HeadRef | Should -BeExactly $headRef + $result.PrereleaseName | Should -BeExactly '' + } } Describe 'Test-PrereleaseCreation' { diff --git a/tests/Release-GHRepository.Tests.ps1 b/tests/Release-GHRepository.Tests.ps1 index 10cdd69..ff0e39f 100644 --- a/tests/Release-GHRepository.Tests.ps1 +++ b/tests/Release-GHRepository.Tests.ps1 @@ -256,6 +256,19 @@ VersionPrefix: v Should -Invoke Write-GitHubNotice -Times 0 -Exactly } + It 'validates a skipped branch even when it has no ASCII prerelease identifier' { + $githubEvent.pull_request.head.ref = ([char] 0x2603).ToString() + $githubEvent.pull_request.labels = @(@{ name = 'release:skip' }) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Skip release:\s+\[True\]' + $output | Should -Match 'no prerelease identifier' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + It 'targets a prerelease at the exact pull-request head without executing publication commands' { $githubEvent.pull_request.labels = @(@{ name = 'release:prerelease' }, @{ name = 'release:minor' }) $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH @@ -269,6 +282,24 @@ VersionPrefix: v Should -Invoke git -Times 0 -Exactly } + Describe 'Publication concurrency settings' { + BeforeAll { + $workflowPath = Join-Path -Path $PSScriptRoot -ChildPath '..\.github\workflows\Release.yml' + $releaseWorkflow = ConvertFrom-Yaml -Yaml (Get-Content -LiteralPath $workflowPath -Raw) + } + + It 'never cancels an in-progress publication run' { + $releaseWorkflow.concurrency.'cancel-in-progress' | Should -BeFalse + } + + It 'serializes merged pull requests by target branch and isolates open pull requests by number' { + $releaseWorkflow.concurrency.group | Should -BeExactly ( + '${{ github.workflow }}-${{ github.event.pull_request.merged && github.event.pull_request.base.ref || ' + + 'github.event.pull_request.number || github.ref }}' + ) + } + } + It 'rejects conflicting owned labels instead of falling back to the default' { $githubEvent.pull_request.labels = @(@{ name = 'release:patch' }, @{ name = 'release:major' }) $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH From b8767c42b5140ea66e865cbd0142cd2f7c2cbde7 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:01:06 +0100 Subject: [PATCH 25/27] Match cleanup identities exactly and keep skip version-free --- src/Release-GHRepository.Helpers.psm1 | 21 ++++++--- src/main.ps1 | 25 ++++++----- tests/Release-GHRepository.Helpers.Tests.ps1 | 46 ++++++++++++++++++++ tests/Release-GHRepository.Tests.ps1 | 22 +++++++++- 4 files changed, 97 insertions(+), 17 deletions(-) diff --git a/src/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 index 81fa389..1d54388 100644 --- a/src/Release-GHRepository.Helpers.psm1 +++ b/src/Release-GHRepository.Helpers.psm1 @@ -238,9 +238,9 @@ function Get-PrereleaseCleanupCandidate { Return prereleases that belong to a pull-request prerelease identifier. .DESCRIPTION - Select only releases marked as prereleases whose tag contains the exact - prerelease identifier immediately after the SemVer hyphen. The identifier - must end the tag or be followed by a dot. + Select only releases marked as prereleases whose first version core is + followed by the exact case-sensitive prerelease identifier. The identifier + must end the tag or be followed by a dot; later hyphens never select it. .EXAMPLE Get-PrereleaseCleanupCandidate -Releases $releases -PrereleaseName 'feature123' @@ -273,11 +273,20 @@ function Get-PrereleaseCleanupCandidate { ) $escapedPrereleaseName = [regex]::Escape($PrereleaseName) - $tagPattern = "-$escapedPrereleaseName(?:\.|$)" + $identifierPattern = "\A$escapedPrereleaseName(?:\.|\z)" + $versionPattern = '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-\z' $Releases | Where-Object { - $_.isPrerelease -eq $true -and - [string] $_.tagName -match $tagPattern + if ($_.isPrerelease -ne $true) { + return $false + } + $tagName = [string] $_.tagName + $separator = [regex]::Match($tagName, '[0-9]+\.[0-9]+\.[0-9]+-') + if (-not $separator.Success -or $separator.Value -cnotmatch $versionPattern) { + return $false + } + $identifier = $tagName.Substring($separator.Index + $separator.Length) + $identifier -cmatch $identifierPattern } } diff --git a/src/main.ps1 b/src/main.ps1 index 012432b..c2e5cb9 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -243,17 +243,22 @@ LogGroup 'Get releases' { $releases | Select-Object -Property name, isPrerelease, isLatest, publishedAt | Format-Table | Out-String } -LogGroup 'Get latest version' { - $latestRelease = $releases | Where-Object { $_.isLatest -eq $true } - $latestRelease | Format-List | Out-String - $latestVersionString = $latestRelease.tagName - if (![string]::IsNullOrEmpty($latestVersionString)) { - $latestVersion = $latestVersionString | ConvertTo-PSSemVer - Write-Output '-------------------------------------------------' - Write-Output 'Latest version:' - $latestVersion | Format-Table - $latestVersion = $latestVersion.ToString() +$latestVersion = $null +if (-not $releaseDecision.Skip) { + LogGroup 'Get latest version' { + $latestRelease = $releases | Where-Object { $_.isLatest -eq $true } + $latestRelease | Format-List | Out-String + $latestVersionString = $latestRelease.tagName + if (![string]::IsNullOrEmpty($latestVersionString)) { + $latestVersion = $latestVersionString | ConvertTo-PSSemVer + Write-Output '-------------------------------------------------' + Write-Output 'Latest version:' + $latestVersion | Format-Table + $latestVersion = $latestVersion.ToString() + } } +} else { + Write-Output 'Skip selected; no release version is resolved.' } Write-Output '-------------------------------------------------' diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 index 8c55c14..47d342f 100644 --- a/tests/Release-GHRepository.Helpers.Tests.ps1 +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -393,6 +393,52 @@ Describe 'Get-PrereleaseCleanupCandidate' { $result.tagName | Should -Be @('v1.2.3-feature.test.1') } + It 'never matches a name after the first SemVer prerelease separator' { + $tagNames = @( + 'v1.2.3-other-feature.1' + 'v1.2.3-other.1-feature.2' + 'v1.2.3-other-v4.5.6-feature.1' + 'v1.2.3-feature.1' + ) + $records = @($tagNames | ForEach-Object { [PSCustomObject]@{ tagName = $_; isPrerelease = $true } }) + + $result = @(Get-PrereleaseCleanupCandidate -Releases $records -PrereleaseName 'feature') + + $result.tagName | Should -Be @('v1.2.3-feature.1') + } + + It 'distinguishes case-sensitive branch identifiers' { + $records = @( + [PSCustomObject]@{ tagName = 'v1.2.3-Feature.1'; isPrerelease = $true } + [PSCustomObject]@{ tagName = 'v1.2.3-feature.1'; isPrerelease = $true } + ) + + $result = @(Get-PrereleaseCleanupCandidate -Releases $records -PrereleaseName 'Feature') + + $result.tagName | Should -Be @('v1.2.3-Feature.1') + } + + It 'ignores non-version tags and invalid version cores' { + $records = @( + [PSCustomObject]@{ tagName = 'notes-feature.1'; isPrerelease = $true } + [PSCustomObject]@{ tagName = 'v01.2.3-feature.1'; isPrerelease = $true } + ) + + @(Get-PrereleaseCleanupCandidate -Releases $records -PrereleaseName 'feature') | + Should -BeNullOrEmpty + } + + It 'retains cleanup across empty and hyphenated version prefixes' { + $records = @( + [PSCustomObject]@{ tagName = '1.2.3-feature.1'; isPrerelease = $true } + [PSCustomObject]@{ tagName = 'release-v1.2.3-feature.2'; isPrerelease = $true } + ) + + $result = @(Get-PrereleaseCleanupCandidate -Releases $records -PrereleaseName 'feature') + + $result.tagName | Should -Be @('1.2.3-feature.1', 'release-v1.2.3-feature.2') + } + It 'returns no candidates for an empty release list' { $result = @(Get-PrereleaseCleanupCandidate -Releases @() -PrereleaseName 'feature') diff --git a/tests/Release-GHRepository.Tests.ps1 b/tests/Release-GHRepository.Tests.ps1 index ff0e39f..8572fff 100644 --- a/tests/Release-GHRepository.Tests.ps1 +++ b/tests/Release-GHRepository.Tests.ps1 @@ -78,6 +78,7 @@ Describe 'Release-GHRepository caller compatibility' { } $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + $releaseRecordsJson = '[{"tagName":"v2.0.3","isLatest":true,"isPrerelease":false}]' Mock Install-PSResource {} Mock Write-GitHubNotice {} Mock git { throw 'A caller compatibility test must never invoke git.' } @@ -86,7 +87,7 @@ Describe 'Release-GHRepository caller compatibility' { if ($args[0] -eq 'repo' -and $args[1] -eq 'view') { '{"defaultBranchRef":{"name":"main"}}' } elseif ($args[0] -eq 'release' -and $args[1] -eq 'list') { - '[{"tagName":"v2.0.3","isLatest":true,"isPrerelease":false}]' + $releaseRecordsJson } else { throw "A caller compatibility test must never mutate GitHub: gh $($args -join ' ')" } @@ -269,6 +270,25 @@ VersionPrefix: v Should -Invoke git -Times 0 -Exactly } + It 'validates a closed skip without parsing a malformed latest version while retaining cleanup' { + $releaseRecordsJson = @' +[{"tagName":"not-a-semver","isLatest":true,"isPrerelease":false},{"tagName":"v2.0.4-featurecaller.1","isLatest":false,"isPrerelease":true}] +'@ + $githubEvent.pull_request.labels = @(@{ name = 'release:skip' }) + $githubEvent.pull_request.merged = $true + $githubEvent.pull_request.state = 'closed' + $githubEvent.action = 'closed' + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Skipping release creation\.' + $output | Should -Match 'WhatIf: gh release delete v2\.0\.4-featurecaller\.1' + $output | Should -Not -Match 'gh release create|git tag|git push' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + It 'targets a prerelease at the exact pull-request head without executing publication commands' { $githubEvent.pull_request.labels = @(@{ name = 'release:prerelease' }, @{ name = 'release:minor' }) $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH From 4007dbeafccd94c731a04a2dd9716a055ba8fcb1 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 1 Nov 2026 22:55:15 +0100 Subject: [PATCH 26/27] Read canonical release settings and reject legacy paths --- action.yml | 2 +- src/main.ps1 | 23 +++- tests/Release-GHRepository.Tests.ps1 | 155 +++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/action.yml b/action.yml index 4354c9d..78bfa79 100644 --- a/action.yml +++ b/action.yml @@ -21,7 +21,7 @@ inputs: The repository settings file. File values override scalar action inputs; explicit CallerOverrides take precedence over file values. required: false - default: .github\auto-release.yml + default: .github\release.settings.yml DefaultBump: 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 diff --git a/src/main.ps1 b/src/main.ps1 index c2e5cb9..aebd1c7 100644 --- a/src/main.ps1 +++ b/src/main.ps1 @@ -69,8 +69,29 @@ LogGroup 'Set configuration' { if ([string]::IsNullOrWhiteSpace($configurationPath)) { throw 'ConfigurationFile must be a nonempty path.' } + $pathResolver = $ExecutionContext.SessionState.Path + $defaultConfigurationPath = $pathResolver.GetUnresolvedProviderPathFromPSPath( + $actionDefinition.inputs.ConfigurationFile.default + ) + $configurationPath = $pathResolver.GetUnresolvedProviderPathFromPSPath($configurationPath) + $legacyConfigurationPath = Join-Path -Path (Split-Path -Path $defaultConfigurationPath -Parent) -ChildPath 'auto-release.yml' + $pathComparison = [StringComparison]::Ordinal + if ($IsWindows) { + $pathComparison = [StringComparison]::OrdinalIgnoreCase + } + $usesDefaultPath = [string]::Equals($configurationPath, $defaultConfigurationPath, $pathComparison) + $usesLegacyPath = [string]::Equals($configurationPath, $legacyConfigurationPath, $pathComparison) + $configurationExists = Test-Path -LiteralPath $configurationPath -PathType Leaf + $legacyExists = Test-Path -LiteralPath $legacyConfigurationPath -PathType Leaf + if ($usesLegacyPath -or ($legacyExists -and ($usesDefaultPath -or -not $configurationExists))) { + throw ( + "Conventional settings [$legacyConfigurationPath] are no longer supported. " + + "Rename or reconcile the file into [$defaultConfigurationPath] and update ConfigurationFile " + + 'together with a compatible release-engine reference, or select an existing custom settings file.' + ) + } $configuration = @{} - if (-not (Test-Path -LiteralPath $configurationPath -PathType Leaf)) { + if (-not $configurationExists) { Write-Output "Configuration file not found at [$configurationPath]" } else { Write-Output "Reading from configuration file [$configurationPath]" diff --git a/tests/Release-GHRepository.Tests.ps1 b/tests/Release-GHRepository.Tests.ps1 index 8572fff..ef9b5bf 100644 --- a/tests/Release-GHRepository.Tests.ps1 +++ b/tests/Release-GHRepository.Tests.ps1 @@ -10,6 +10,8 @@ $ErrorActionPreference = 'Stop' BeforeAll { Import-Module -Name GitHub, powershell-yaml, PSSemVer $mainPath = Join-Path -Path $PSScriptRoot -ChildPath '..\src\main.ps1' + $actionPath = Join-Path -Path $PSScriptRoot -ChildPath '..\action.yml' + $actionDefinition = ConvertFrom-Yaml -Yaml (Get-Content -LiteralPath $actionPath -Raw) $headSha = '0123456789012345678901234567890123456789' $authoredBody = @' This release preserves the source-bound adoption record. @@ -78,6 +80,10 @@ Describe 'Release-GHRepository caller compatibility' { } $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + $callerDirectory = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString('N')) + $null = New-Item -Path (Join-Path -Path $callerDirectory -ChildPath '.github') -ItemType Directory + Push-Location -LiteralPath $callerDirectory + $releaseRecordsJson = '[{"tagName":"v2.0.3","isLatest":true,"isPrerelease":false}]' Mock Install-PSResource {} Mock Write-GitHubNotice {} @@ -95,6 +101,7 @@ Describe 'Release-GHRepository caller compatibility' { } AfterEach { + Pop-Location foreach ($name in $environmentNames) { if ($null -eq $savedEnvironment[$name]) { if (Test-Path -LiteralPath "Env:\$name") { @@ -106,6 +113,154 @@ Describe 'Release-GHRepository caller compatibility' { } } + It 'declares release.settings.yml as the only conventional settings default' { + $actionDefinition.inputs.ConfigurationFile.default | Should -BeExactly '.github\release.settings.yml' + $actionDefinition.runs.steps[0].env.PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile | + Should -BeExactly '${{ inputs.ConfigurationFile }}' + } + + It 'reads the declared default without an injected ConfigurationFile input' { + Remove-Item -LiteralPath Env:\PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + @' +DefaultBump: minor +WhatIf: true +'@ | Set-Content -LiteralPath '.github\release.settings.yml' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Reading from configuration file .*release\.settings\.yml' + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'Default bump source:\s+\[RepositorySetting\]' + $output | Should -Match 'gh release create v2\.1\.0 ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'inherits valid defaults when the conventional settings file is intentionally absent' { + Remove-Item -LiteralPath Env:\PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Configuration file not found at .*release\.settings\.yml' + $output | Should -Match 'Default bump:\s+\[patch\]' + $output | Should -Match 'Default bump source:\s+\[SharedDefault\]' + $output | Should -Match 'gh release create v2\.0\.4 ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects legacy-only conventional settings instead of silently applying another policy' { + Remove-Item -LiteralPath Env:\PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + @' +DefaultBump: major +WhatIf: false +'@ | Set-Content -LiteralPath '.github\auto-release.yml' + + { & $mainPath } | + Should -Throw '*auto-release.yml*no longer supported*release.settings.yml*ConfigurationFile*' + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects a legacy conventional file even when the new default file also exists' { + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = '.github\release.settings.yml' + 'DefaultBump: major' | Set-Content -LiteralPath '.github\auto-release.yml' + 'DefaultBump: minor' | Set-Content -LiteralPath '.github\release.settings.yml' + + { & $mainPath } | Should -Throw '*auto-release.yml*no longer supported*release.settings.yml*' + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects the removed conventional path even when explicitly supplied as ' -ForEach @( + @{ PathKind = 'relative'; PathValue = '.github\auto-release.yml' } + @{ PathKind = 'normalized relative'; PathValue = '.github\..\.github\auto-release.yml' } + @{ PathKind = 'absolute'; PathValue = $null } + ) { + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = $PathValue + if ($PathKind -eq 'absolute') { + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = Join-Path -Path $callerDirectory -ChildPath '.github\auto-release.yml' + } + @' +DefaultBump: major +WhatIf: false +'@ | Set-Content -LiteralPath '.github\auto-release.yml' + + { & $mainPath } | Should -Throw '*auto-release.yml*no longer supported*release.settings.yml*' + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'preserves an intentional custom settings path without adding discovery or fallback' { + 'DefaultBump: major' | Set-Content -LiteralPath '.github\auto-release.yml' + 'DefaultBump: major' | Set-Content -LiteralPath '.github\release.settings.yml' + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = Join-Path -Path $callerDirectory -ChildPath 'custom.settings.yml' + @' +DefaultBump: minor +WhatIf: false +'@ | Set-Content -LiteralPath $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = 'WhatIf: true' + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Reading from configuration file .*custom\.settings\.yml' + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'gh release create v2\.1\.0 ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'does not ignore legacy settings when an explicitly selected custom file is missing' { + $env:PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile = 'missing.settings.yml' + 'DefaultBump: major' | Set-Content -LiteralPath '.github\auto-release.yml' + + { & $mainPath } | Should -Throw '*auto-release.yml*no longer supported*release.settings.yml*ConfigurationFile*' + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'retains scope precedence when the new default file is selected without injection' { + Remove-Item -LiteralPath Env:\PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $env:PSMODULE_AUTO_RELEASE_INPUT_DefaultBump = 'patch' + @' +DefaultBump: major +WhatIf: false +'@ | Set-Content -LiteralPath '.github\release.settings.yml' + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = @' +DefaultBump: minor +WhatIf: true +'@ + $githubEvent.pull_request.labels = @(@{ name = 'release:patch' }) + $githubEvent | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $env:GITHUB_EVENT_PATH + + $output = (& $mainPath) -join "`n" + + $output | Should -Match 'Reading from configuration file .*release\.settings\.yml' + $output | Should -Match 'Default bump:\s+\[minor\]' + $output | Should -Match 'Default bump source:\s+\[CallerOverride\]' + $output | Should -Match 'Bump source:\s+\[release:patch\]' + $output | Should -Match 'gh release create v2\.0\.4 ' + Should -Invoke gh -Times 2 -Exactly + Should -Invoke git -Times 0 -Exactly + } + + It 'rejects invalid data in the new default file before contacting GitHub' -ForEach @( + @{ Yaml = 'DefaultBump: date'; Message = '*Invalid DefaultBump*' } + @{ Yaml = 'DefaultBump: null'; Message = '*DefaultBump*' } + @{ Yaml = 'WhatIf: invalid'; Message = '*WhatIf*true or false*' } + @{ Yaml = 'AutoPatching: false'; Message = '*AutoPatching*removed*' } + @{ Yaml = '- patch'; Message = '*configuration must be a mapping*' } + @{ Yaml = ''; Message = '*configuration*must be a mapping*' } + ) { + Remove-Item -LiteralPath Env:\PSMODULE_AUTO_RELEASE_INPUT_ConfigurationFile + $Yaml | Set-Content -LiteralPath '.github\release.settings.yml' + $env:PSMODULE_AUTO_RELEASE_INPUT_CallerOverrides = 'DefaultBump: patch' + + { & $mainPath } | Should -Throw $Message + Should -Invoke gh -Times 0 -Exactly + Should -Invoke git -Times 0 -Exactly + } + It 'uses action.yml defaults without relying on the composite wrapper to inject them' { $output = (& $mainPath) -join "`n" From 03e20b8293dd8dfb41bead3255930a5edbb2e9bb Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 1 Nov 2026 22:55:15 +0100 Subject: [PATCH 27/27] Document the canonical release settings reader --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1518ef2..f14770c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,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 | | `CallerOverrides` | Supply a YAML or JSON mapping of explicit invocation overrides. These override repository settings. | `{}` | false | -| `ConfigurationFile` | Read repository settings from this file. File values override scalar action inputs, but not `CallerOverrides`. | `.github\auto-release.yml` | false | +| `ConfigurationFile` | Read repository settings from this file. File values override scalar action inputs, but not `CallerOverrides`. | `.github\release.settings.yml` | 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 | @@ -106,9 +106,11 @@ The `pull_request_target` workflow checks out the trusted base branch. Do not ch Use the `release:prerelease` label to select repository prerelease behavior. The similarly named `Prerelease` action input only controls dependency resolution for the GitHub module used internally. -### Configuration file +### Settings file -The default configuration file is `.github\auto-release.yml`. Change its path with `ConfigurationFile`. +The optional repository settings file is `.github\release.settings.yml`. When it is absent, valid action defaults and caller inputs still apply. `ConfigurationFile` preserves explicit control over a custom settings path; it does not add another conventional filename or search path. + +The removed conventional `.github\auto-release.yml` path has no reader fallback or alias, even when selected explicitly. Its presence alongside the default settings path fails before GitHub operations instead of silently changing the release policy. An existing explicitly selected custom file is read as directed; a missing selected file cannot silently discard legacy conventional settings. The release record contains the exact filename/reader adoption steps. Settings resolve in this order: defaults declared in `action.yml`, scalar action inputs, repository settings, then explicit `CallerOverrides`. Scalar inputs retain the existing file-over-input behavior because GitHub injects their defaults even when callers omit them. Use `CallerOverrides` for an invocation-specific override; its omitted `{}` mapping inherits repository settings without masking them. @@ -117,12 +119,20 @@ An explicit `false` or empty string is an override, not an omitted setting. Bool The file and `CallerOverrides` must contain mappings. They accept the release settings from `AutoCleanup` through `WhatIf` in the input table, excluding `CallerOverrides` and `ConfigurationFile` themselves. The dependency/runtime inputs (`Debug`, `Verbose`, `Version`, `Prerelease`, and `WorkingDirectory`) are supplied separately by the caller. Unknown keys, removed keys, null values, and invalid values fail before label provisioning. ```yaml +# .github\release.settings.yml DatePrereleaseFormat: 'yyyyMMddHHmm' DefaultBump: patch IncrementalPrerelease: false VersionPrefix: '' ``` +For an intentional custom path: + +```yaml +with: + ConfigurationFile: .github\custom-release.settings.yml +``` + For an invocation-specific minor bump, even when the repository configures major: ```yaml