diff --git a/.github/actions/Publish-PSModule/action.yml b/.github/actions/Publish-PSModule/action.yml index 0ff0a534..50ab4eac 100644 --- a/.github/actions/Publish-PSModule/action.yml +++ b/.github/actions/Publish-PSModule/action.yml @@ -37,6 +37,13 @@ inputs: description: Name of the uploaded artifact to download. Must match the name used in the upstream upload-artifact step. required: false default: module + VersionPrefix: + description: | + Prefix put in front of the version in the git tag of the GitHub release, for example 'v'. + Comes from Settings.Publish.Module.VersionPrefix, which the Plan job resolves. The compiled manifest carries the module version as + Major.Minor.Patch and cannot carry the prefix, so it is supplied here. An empty value tags the release without a prefix. + required: false + default: '' runs: using: composite @@ -65,4 +72,5 @@ runs: PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRBodyAsReleaseNotes: ${{ inputs.UsePRBodyAsReleaseNotes }} PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} + PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix: ${{ inputs.VersionPrefix }} run: ${{ github.action_path }}/src/publish.ps1 diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 new file mode 100644 index 00000000..b7fe3253 --- /dev/null +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -0,0 +1,104 @@ +function Get-ModuleVersionString { + <# + .SYNOPSIS + Builds the SemVer version string that identifies the module itself. + + .DESCRIPTION + Composes the module version and, when there is one, the prerelease label. This is the string the + PowerShell Gallery and the module manifest understand: `Major.Minor.Patch` optionally followed by + `-`. It never carries the repository's version prefix, because neither the manifest's + `ModuleVersion` nor a Gallery package version accepts one. + + .OUTPUTS + String with the module version. + + .EXAMPLE + Get-ModuleVersionString -ModuleVersion '1.1.10' + + Returns '1.1.10'. + + .EXAMPLE + Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + + Returns '1.1.10-mybranch001'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The module version from the compiled manifest, in Major.Minor.Patch format. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleVersion, + + # The prerelease label from the compiled manifest. Empty for a stable release. + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string] $Prerelease + ) + + if ([string]::IsNullOrWhiteSpace($Prerelease)) { + return $ModuleVersion + } + + "$ModuleVersion-$($Prerelease.Trim())" +} + +function Get-ReleaseTag { + <# + .SYNOPSIS + Builds the git tag used for the GitHub release. + + .DESCRIPTION + Prefixes the module's SemVer version string with the configured version prefix. The version comes + from the compiled manifest, which is the artifact that is published, so the tag always names the + exact bytes that were tested and pushed to the PowerShell Gallery. The manifest's ModuleVersion is + Major.Minor.Patch by definition and cannot carry the prefix, so the prefix is supplied from the + resolved settings (Publish.Module.VersionPrefix) instead. + + The prefix belongs to the GitHub release tag and to nothing else. PowerShell manifests and Gallery + package versions only accept plain SemVer, so callers that need the module's own version use + Get-ModuleVersionString. Deriving both from the same composition keeps the prefix as the only + difference between them. + + .OUTPUTS + String with the release tag. + + .EXAMPLE + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' + + Returns 'v1.1.10'. + + .EXAMPLE + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + + Returns 'v1.1.10-mybranch001'. + + .EXAMPLE + Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' + + Returns '1.1.10'. + #> + [CmdletBinding()] + [OutputType([string])] + param( + # The module version from the compiled manifest, in Major.Minor.Patch format. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $ModuleVersion, + + # The prefix put in front of the version, for example 'v'. Empty for an unprefixed repository. + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string] $VersionPrefix, + + # The prerelease label from the compiled manifest. Empty for a stable release. + [Parameter()] + [AllowEmptyString()] + [AllowNull()] + [string] $Prerelease + ) + + "$($VersionPrefix.Trim())$(Get-ModuleVersionString -ModuleVersion $ModuleVersion -Prerelease $Prerelease)" +} diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index b9f76254..3c91c27c 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -28,6 +28,7 @@ param() $PSStyle.OutputRendering = 'Ansi' Import-Module -Name 'PSModule' -Force +Import-Module -Name "$PSScriptRoot/Publish-PSModule.Helpers.psm1" -Force #region Load inputs LogGroup 'Load inputs' { @@ -58,10 +59,14 @@ LogGroup 'Load inputs' { $usePRBodyAsReleaseNotes = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRBodyAsReleaseNotes -eq 'true' $usePRTitleAsReleaseName = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true' $usePRTitleAsNotesHeading = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true' + # The prefix the repository tags releases with, resolved by the Plan job from + # Settings.Publish.Module.VersionPrefix. Empty means the repository tags without a prefix. + $versionPrefix = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix - Write-Host "Module name: [$name]" - Write-Host "Module path: [$modulePath]" - Write-Host "WhatIf: [$whatIf]" + Write-Host "Module name: [$name]" + Write-Host "Module path: [$modulePath]" + Write-Host "Version prefix: [$versionPrefix]" + Write-Host "WhatIf: [$whatIf]" } #endregion Load inputs @@ -129,12 +134,18 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $true } - $releaseTag = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } + # The PowerShell Gallery and the module manifest only accept plain SemVer, so the configured + # VersionPrefix is applied to the GitHub release tag and to nothing else. Both strings are derived + # from the same composition here, so the prefix is the only difference between them. + $publishPSVersion = Get-ModuleVersionString -ModuleVersion $moduleVersion -Prerelease $prerelease + $releaseTag = Get-ReleaseTag -VersionPrefix $versionPrefix -ModuleVersion $moduleVersion -Prerelease $prerelease [PSCustomObject]@{ ModuleVersion = $moduleVersion + VersionPrefix = $versionPrefix Prerelease = $prerelease CreatePrerelease = $createPrerelease + GalleryVersion = $publishPSVersion ReleaseTag = $releaseTag PRNumber = $prNumber PRHeadRef = $prHeadRef @@ -154,7 +165,6 @@ LogGroup 'Install module dependencies' { #region Publish to PSGallery LogGroup 'Publish to PSGallery' { $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } - $publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } $psGalleryReleaseLink = "https://www.powershellgallery.com/packages/$name/$publishPSVersion" Write-Host 'Publish module to PowerShell Gallery using API key from environment.' @@ -280,4 +290,4 @@ LogGroup 'Create GitHub release' { } #endregion Create GitHub release -Write-Host "Publishing complete. Version: [$releaseTag]" +Write-Host "Publishing complete. PowerShell Gallery version: [$publishPSVersion]. GitHub release tag: [$releaseTag]." diff --git a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 new file mode 100644 index 00000000..357a4ef2 --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 @@ -0,0 +1,167 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' +)] +[CmdletBinding()] +param() + +BeforeAll { + Import-Module -Name 'PSModule' -Force + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/Publish-PSModule.Helpers.psm1') -Force +} + +Describe 'Publish-PSModule.Helpers' { + Describe 'Get-ModuleVersionString' { + Context 'Get-ModuleVersionString - SemVer only, never prefixed' { + It 'Get-ModuleVersionString - returns the module version for a stable release' { + Get-ModuleVersionString -ModuleVersion '1.1.10' | Should -Be '1.1.10' + } + + It 'Get-ModuleVersionString - appends the prerelease label' { + Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease 'mybranch001' | + Should -Be '1.1.10-mybranch001' + } + + It 'Get-ModuleVersionString - treats an empty prerelease label as a stable release' { + Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease '' | Should -Be '1.1.10' + } + + It 'Get-ModuleVersionString - treats a whitespace-only prerelease label as a stable release' { + Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease ' ' | Should -Be '1.1.10' + } + + It 'Get-ModuleVersionString - trims whitespace around the prerelease label' { + Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease ' mybranch001 ' | + Should -Be '1.1.10-mybranch001' + } + + It 'Get-ModuleVersionString - takes no version prefix parameter at all' { + (Get-Command Get-ModuleVersionString).Parameters.Keys | Should -Not -Contain 'VersionPrefix' + } + + It 'Get-ModuleVersionString - requires a module version' { + { Get-ModuleVersionString -ModuleVersion '' } | Should -Throw + } + } + } + + Describe 'Get-ReleaseTag' { + Context 'Get-ReleaseTag - repository with a version prefix' { + It 'Get-ReleaseTag - prefixes a stable release tag with the configured prefix' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' | Should -Be 'v1.1.10' + } + + It 'Get-ReleaseTag - prefixes a stable release tag when the prerelease label is empty' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease '' | Should -Be 'v1.1.10' + } + + It 'Get-ReleaseTag - prefixes a prerelease tag with the configured prefix' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' | + Should -Be 'v1.1.10-mybranch001' + } + + It 'Get-ReleaseTag - supports a multi-character prefix' { + Get-ReleaseTag -VersionPrefix 'release-v' -ModuleVersion '2.0.0' | Should -Be 'release-v2.0.0' + } + } + + Context 'Get-ReleaseTag - repository without a version prefix' { + It 'Get-ReleaseTag - leaves a stable release tag unprefixed' { + Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' | Should -Be '1.1.10' + } + + It 'Get-ReleaseTag - leaves a prerelease tag unprefixed' { + Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' | + Should -Be '1.1.10-mybranch001' + } + + It 'Get-ReleaseTag - treats an absent prefix as no prefix' { + Get-ReleaseTag -ModuleVersion '1.1.10' | Should -Be '1.1.10' + } + + It 'Get-ReleaseTag - treats a null prefix as no prefix' { + Get-ReleaseTag -VersionPrefix $null -ModuleVersion '1.1.10' | Should -Be '1.1.10' + } + } + + Context 'Get-ReleaseTag - input normalization' { + It 'Get-ReleaseTag - trims whitespace around the prefix' { + Get-ReleaseTag -VersionPrefix ' v ' -ModuleVersion '1.1.10' | Should -Be 'v1.1.10' + } + + It 'Get-ReleaseTag - trims whitespace around the prerelease label' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease ' mybranch001 ' | + Should -Be 'v1.1.10-mybranch001' + } + + It 'Get-ReleaseTag - treats a whitespace-only prerelease label as a stable release' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease ' ' | Should -Be 'v1.1.10' + } + + It 'Get-ReleaseTag - requires a module version' { + { Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '' } | Should -Throw + } + } + + # Cleanup-PSModulePrereleases selects the releases to delete with + # `tagName -like "*$prereleaseName*" -and tagName -ne $publishedReleaseTag`, where the published tag is + # the value publish.ps1 exports as PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag. Both halves of that + # filter have to keep working once the tag carries a prefix. + Context 'Get-ReleaseTag - AutoCleanup tag matching contract' { + It 'Get-ReleaseTag - keeps the prerelease name inside a prefixed tag so cleanup still matches it' { + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' | + Should -BeLike '*mybranch*' + } + + It 'Get-ReleaseTag - keeps the prerelease name inside an unprefixed tag so cleanup still matches it' { + Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' | + Should -BeLike '*mybranch*' + } + + It 'Get-ReleaseTag - produces the same tag twice so cleanup can exclude the published release' { + $first = Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $second = Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $first | Should -Be $second + } + } + + # The PowerShell Gallery and the module manifest only accept plain SemVer. The prefix therefore + # belongs to the GitHub release tag and to nothing else, and the two strings must differ by exactly + # the prefix - never by anything else, and never in the other direction. + Context 'Get-ReleaseTag - the prefix reaches the release tag and nothing else' { + It 'Get-ReleaseTag - the tag is the prefix followed by the module version string' -ForEach @( + @{ Prefix = 'v'; Version = '1.1.10'; Label = '' } + @{ Prefix = 'v'; Version = '1.1.10'; Label = 'mybranch001' } + @{ Prefix = ''; Version = '1.1.10'; Label = '' } + @{ Prefix = ''; Version = '1.1.10'; Label = 'mybranch001' } + @{ Prefix = 'release-v'; Version = '2.0.0'; Label = 'mybranch001' } + ) { + $moduleVersion = Get-ModuleVersionString -ModuleVersion $Version -Prerelease $Label + $tag = Get-ReleaseTag -VersionPrefix $Prefix -ModuleVersion $Version -Prerelease $Label + $tag | Should -Be "$Prefix$moduleVersion" + } + + It 'Get-ReleaseTag - the module version string never gains the prefix' -ForEach @( + @{ Prefix = 'v'; Version = '1.1.10'; Label = '' } + @{ Prefix = 'v'; Version = '1.1.10'; Label = 'mybranch001' } + @{ Prefix = 'release-v'; Version = '2.0.0'; Label = 'mybranch001' } + ) { + $moduleVersion = Get-ModuleVersionString -ModuleVersion $Version -Prerelease $Label + $moduleVersion | Should -Not -BeLike "$Prefix*" + $moduleVersion | Should -Match '^\d+\.\d+\.\d+(-[0-9A-Za-z\-.]+)?$' + } + + It 'Get-ReleaseTag - an unprefixed repository gets identical strings' { + $moduleVersion = Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $tag = Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $tag | Should -Be $moduleVersion + } + + It 'Get-ReleaseTag - stripping the prefix from the tag yields the module version string' { + $moduleVersion = Get-ModuleVersionString -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $tag = Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + $tag -replace '^v' | Should -Be $moduleVersion + } + } + } +} diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index ee1bbcb6..1c8379b5 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -50,6 +50,7 @@ jobs: UsePRTitleAsReleaseName: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsReleaseName }} UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }} UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }} + VersionPrefix: ${{ fromJson(inputs.Settings).Publish.Module.VersionPrefix }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Cleanup prereleases