diff --git a/.github/workflows/Action-Test.yml b/.github/workflows/Action-Test.yml index b2a7d72..89aeb86 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,32 @@ 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 + 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..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: + pull_request_target: # zizmor: ignore[dangerous-triggers] trusted base checkout; writes labels and releases branches: - main types: @@ -12,16 +12,15 @@ on: - reopened - synchronize - labeled - paths: - - 'action.yml' - - 'src/**' + - unlabeled concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true 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: diff --git a/README.md b/README.md index 9bcd513..89b1bae 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 non-WhatIf 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,75 @@ 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 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 | +| `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. 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/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/Release-GHRepository.Helpers.psm1 b/src/Release-GHRepository.Helpers.psm1 new file mode 100644 index 0000000..3154e51 --- /dev/null +++ b/src/Release-GHRepository.Helpers.psm1 @@ -0,0 +1,199 @@ +$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 + } +} + +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 089a75f..511af95 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,38 @@ 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' + ) + if ($whatIf) { + Write-Output "WhatIf: gh $($arguments -join ' ')" + } else { + 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 +147,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 = Test-PrereleaseCreation -ReleaseDecision $releaseDecision -PullRequestClosed:$prIsClosed $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 +194,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 +388,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 diff --git a/tests/Release-GHRepository.Helpers.Tests.ps1 b/tests/Release-GHRepository.Helpers.Tests.ps1 new file mode 100644 index 0000000..a845543 --- /dev/null +++ b/tests/Release-GHRepository.Helpers.Tests.ps1 @@ -0,0 +1,262 @@ +[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 + } + + 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" + } + } + } + + 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 + } + } +}