From fd2c7d95986415119c792d507894c12ecb75b78c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:04:22 +0200 Subject: [PATCH 1/7] Extract release tag derivation into a testable helper Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Publish-PSModule.Helpers.psm1 | 44 +++++++++++++++++++ .../actions/Publish-PSModule/src/publish.ps1 | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 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..1bf02caa --- /dev/null +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -0,0 +1,44 @@ +function Get-ReleaseTag { + <# + .SYNOPSIS + Builds the git tag used for the GitHub release. + + .DESCRIPTION + Composes the release tag from the module version and, when present, the prerelease label. + 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. + + .OUTPUTS + String with the release tag. + + .EXAMPLE + Get-ReleaseTag -ModuleVersion '1.1.10' + + Returns '1.1.10'. + + .EXAMPLE + Get-ReleaseTag -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())" +} diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index b9f76254..8463a980 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' { @@ -129,7 +130,7 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $true } - $releaseTag = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } + $releaseTag = Get-ReleaseTag -ModuleVersion $moduleVersion -Prerelease $prerelease [PSCustomObject]@{ ModuleVersion = $moduleVersion From d0f8f7a13bfdd56c38568a2640844d07a80474ba Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:05:51 +0200 Subject: [PATCH 2/7] Add regression test for the version prefix on release tags The test asserts the tag a repository with VersionPrefix 'v' expects, and keeps an unprefixed repository covered. It fails against the current derivation, which builds the tag from the manifest's Major.Minor.Patch only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/Publish-PSModule.Helpers.Tests.ps1 | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 .github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 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..38613406 --- /dev/null +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 @@ -0,0 +1,94 @@ +[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-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 + } + } + } +} From 06b4714479a0e58e29b4073009c7b4c982bf68b6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:06:45 +0200 Subject: [PATCH 3/7] Apply the configured version prefix to the created release tag The Plan job resolves Publish.Module.VersionPrefix, but the publish action never received it, so the tag was built from the manifest's Major.Minor.Patch alone and repositories on the default 'v' prefix lost it. The prefix now flows from Settings through the action input into the tag. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Publish-PSModule/action.yml | 8 +++++ .../src/Publish-PSModule.Helpers.psm1 | 34 ++++++++++++++----- .../actions/Publish-PSModule/src/publish.ps1 | 13 ++++--- .github/workflows/Publish-Module.yml | 1 + 4 files changed, 43 insertions(+), 13 deletions(-) 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 index 1bf02caa..12883770 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -4,22 +4,30 @@ Builds the git tag used for the GitHub release. .DESCRIPTION - Composes the release tag from the module version and, when present, the prerelease label. - 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. + Composes the release tag from the configured version prefix, the module version, and the + prerelease label when there is one. 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. An empty prefix produces an unprefixed tag. .OUTPUTS String with the release tag. .EXAMPLE - Get-ReleaseTag -ModuleVersion '1.1.10' + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' - Returns '1.1.10'. + Returns 'v1.1.10'. .EXAMPLE - Get-ReleaseTag -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + Get-ReleaseTag -VersionPrefix 'v' -ModuleVersion '1.1.10' -Prerelease 'mybranch001' + + Returns 'v1.1.10-mybranch001'. - Returns '1.1.10-mybranch001'. + .EXAMPLE + Get-ReleaseTag -VersionPrefix '' -ModuleVersion '1.1.10' + + Returns '1.1.10'. #> [CmdletBinding()] [OutputType([string])] @@ -29,6 +37,12 @@ [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()] @@ -36,9 +50,11 @@ [string] $Prerelease ) + $tag = "$($VersionPrefix.Trim())$ModuleVersion" + if ([string]::IsNullOrWhiteSpace($Prerelease)) { - return $ModuleVersion + return $tag } - "$ModuleVersion-$($Prerelease.Trim())" + "$tag-$($Prerelease.Trim())" } diff --git a/.github/actions/Publish-PSModule/src/publish.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 8463a980..18010d5b 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -59,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 @@ -130,10 +134,11 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $true } - $releaseTag = Get-ReleaseTag -ModuleVersion $moduleVersion -Prerelease $prerelease + $releaseTag = Get-ReleaseTag -VersionPrefix $versionPrefix -ModuleVersion $moduleVersion -Prerelease $prerelease [PSCustomObject]@{ ModuleVersion = $moduleVersion + VersionPrefix = $versionPrefix Prerelease = $prerelease CreatePrerelease = $createPrerelease ReleaseTag = $releaseTag 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 From 29e2329ddd4fbf7506770fcb84a2cbd831b154aa Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:29:31 +0200 Subject: [PATCH 4/7] Trigger the self-test publish path from the Fix label The publish job was skipped in every self-test run, so the Settings to action input to environment variable hop was never exercised in CI. That is the hop this pull request repairs. Adding 'Fix' to the Default fixture's PrereleaseLabels resolves ReleaseType to Prerelease for the framework's own bugfix pull requests, so Publish-PSModule runs under WhatIf and logs the tag it would create. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/srcTestRepo/.github/PSModule.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/srcTestRepo/.github/PSModule.yml b/tests/srcTestRepo/.github/PSModule.yml index 92d30f1e..2062aaa7 100644 --- a/tests/srcTestRepo/.github/PSModule.yml +++ b/tests/srcTestRepo/.github/PSModule.yml @@ -1,3 +1,18 @@ Name: PSModuleTest2 Linter: Skip: true +Publish: + Module: + # 'Fix' is here so that Process-PSModule's own self-test exercises the publish path. The framework + # repository labels its bugfix pull requests 'Fix', which makes Get-PSModuleSettings resolve + # ReleaseType to 'Prerelease' for this fixture, so Publish-PSModule actually runs and its resolved + # release tag is visible in the workflow log. Publish-Module.yml sets WhatIf whenever the workflow + # runs in PSModule/Process-PSModule, so nothing is published and no release is created. + # + # Never add a label that PSModule/Auto-Release recognises. It matches the literal string 'prerelease' + # with -Contains, which is case-insensitive in PowerShell, and would make this repository create a + # real prerelease release and tag of itself. + # + # This is a stopgap that covers the Settings -> action input -> environment variable hop until the + # end-to-end publish harness in PSModule/Process-PSModule#436 exists. + PrereleaseLabels: 'prerelease, Fix' From e20f1d4ce2e1258f873ebf33026201f9e646eb2e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 2 Aug 2026 19:45:59 +0200 Subject: [PATCH 5/7] Move the self-test publish trigger to the WithManifest fixture Keeps the Default fixture at ReleaseType None so it stays available as the fixture that can exercise Cleanup-PSModulePrereleases, which is gated on ReleaseType != Prerelease. Both fixtures call the same workflow.yml, so the Settings to input to environment variable hop is proven identically either way. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/srcTestRepo/.github/PSModule.yml | 15 --------------- .../.github/PSModule.yml | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/srcTestRepo/.github/PSModule.yml b/tests/srcTestRepo/.github/PSModule.yml index 2062aaa7..92d30f1e 100644 --- a/tests/srcTestRepo/.github/PSModule.yml +++ b/tests/srcTestRepo/.github/PSModule.yml @@ -1,18 +1,3 @@ Name: PSModuleTest2 Linter: Skip: true -Publish: - Module: - # 'Fix' is here so that Process-PSModule's own self-test exercises the publish path. The framework - # repository labels its bugfix pull requests 'Fix', which makes Get-PSModuleSettings resolve - # ReleaseType to 'Prerelease' for this fixture, so Publish-PSModule actually runs and its resolved - # release tag is visible in the workflow log. Publish-Module.yml sets WhatIf whenever the workflow - # runs in PSModule/Process-PSModule, so nothing is published and no release is created. - # - # Never add a label that PSModule/Auto-Release recognises. It matches the literal string 'prerelease' - # with -Contains, which is case-insensitive in PowerShell, and would make this repository create a - # real prerelease release and tag of itself. - # - # This is a stopgap that covers the Settings -> action input -> environment variable hop until the - # end-to-end publish harness in PSModule/Process-PSModule#436 exists. - PrereleaseLabels: 'prerelease, Fix' diff --git a/tests/srcWithManifestTestRepo/.github/PSModule.yml b/tests/srcWithManifestTestRepo/.github/PSModule.yml index 4ecb9116..dbca6ad0 100644 --- a/tests/srcWithManifestTestRepo/.github/PSModule.yml +++ b/tests/srcWithManifestTestRepo/.github/PSModule.yml @@ -12,6 +12,23 @@ Test: Publish: Module: AutoCleanup: false + # 'Fix' is here so that Process-PSModule's own self-test exercises the publish path. The framework + # repository labels its bugfix pull requests 'Fix', which makes Get-PSModuleSettings resolve + # ReleaseType to 'Prerelease' for this fixture, so Publish-PSModule actually runs and the release tag + # it resolves is visible in the workflow log. Publish-Module.yml sets WhatIf whenever the workflow runs + # in PSModule/Process-PSModule, so nothing is published and no release is created. + # + # Never add a label that PSModule/Auto-Release recognises. It matches the literal string 'prerelease' + # with -Contains, which is case-insensitive in PowerShell, and would make this repository create a + # real prerelease release and tag of itself. + # + # This fixture carries it rather than srcTestRepo so that the Default fixture, which leaves AutoCleanup + # at its default of true, keeps resolving ReleaseType to 'None' and stays available as the fixture that + # can exercise Cleanup-PSModulePrereleases - that step is gated on ReleaseType != 'Prerelease'. + # + # This is a stopgap that covers the Settings -> action input -> environment variable hop until the + # end-to-end publish harness in PSModule/Process-PSModule#436 exists. + PrereleaseLabels: 'prerelease, Fix' Linter: env: VALIDATE_BIOME_FORMAT: false From 2d23d93ea2f2ff2f911276b34010726ec8738e50 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 3 Aug 2026 03:06:52 +0200 Subject: [PATCH 6/7] Reset the WithManifest fixture settings Drops the PrereleaseLabels trigger, so the self-test returns to skipping Publish-Module at the job level and the pull request is limited to the tag-derivation path again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../.github/PSModule.yml | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/srcWithManifestTestRepo/.github/PSModule.yml b/tests/srcWithManifestTestRepo/.github/PSModule.yml index dbca6ad0..4ecb9116 100644 --- a/tests/srcWithManifestTestRepo/.github/PSModule.yml +++ b/tests/srcWithManifestTestRepo/.github/PSModule.yml @@ -12,23 +12,6 @@ Test: Publish: Module: AutoCleanup: false - # 'Fix' is here so that Process-PSModule's own self-test exercises the publish path. The framework - # repository labels its bugfix pull requests 'Fix', which makes Get-PSModuleSettings resolve - # ReleaseType to 'Prerelease' for this fixture, so Publish-PSModule actually runs and the release tag - # it resolves is visible in the workflow log. Publish-Module.yml sets WhatIf whenever the workflow runs - # in PSModule/Process-PSModule, so nothing is published and no release is created. - # - # Never add a label that PSModule/Auto-Release recognises. It matches the literal string 'prerelease' - # with -Contains, which is case-insensitive in PowerShell, and would make this repository create a - # real prerelease release and tag of itself. - # - # This fixture carries it rather than srcTestRepo so that the Default fixture, which leaves AutoCleanup - # at its default of true, keeps resolving ReleaseType to 'None' and stays available as the fixture that - # can exercise Cleanup-PSModulePrereleases - that step is gated on ReleaseType != 'Prerelease'. - # - # This is a stopgap that covers the Settings -> action input -> environment variable hop until the - # end-to-end publish harness in PSModule/Process-PSModule#436 exists. - PrereleaseLabels: 'prerelease, Fix' Linter: env: VALIDATE_BIOME_FORMAT: false From f017813fd49fffbcd883a73fd84e49cd24e3f305 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Mon, 3 Aug 2026 03:12:06 +0200 Subject: [PATCH 7/7] Keep the version prefix on the release tag only The PowerShell Gallery and the module manifest only accept plain SemVer, so the prefix must not reach either. Both version strings now come from one composition, Get-ModuleVersionString, with Get-ReleaseTag adding the prefix on top, which removes the duplicated prerelease handling that could have drifted. The resolved-version summary and the closing log line report both strings so the separation is visible in the log. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/Publish-PSModule.Helpers.psm1 | 72 ++++++++++++++---- .../actions/Publish-PSModule/src/publish.ps1 | 8 +- .../tests/Publish-PSModule.Helpers.Tests.ps1 | 73 +++++++++++++++++++ 3 files changed, 137 insertions(+), 16 deletions(-) diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index 12883770..b7fe3253 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -1,15 +1,65 @@ -function Get-ReleaseTag { +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 - Composes the release tag from the configured version prefix, the module version, and the - prerelease label when there is one. 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. An empty prefix produces an unprefixed tag. + 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. @@ -50,11 +100,5 @@ [string] $Prerelease ) - $tag = "$($VersionPrefix.Trim())$ModuleVersion" - - if ([string]::IsNullOrWhiteSpace($Prerelease)) { - return $tag - } - - "$tag-$($Prerelease.Trim())" + "$($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 18010d5b..3c91c27c 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -134,6 +134,10 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $true } + # 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]@{ @@ -141,6 +145,7 @@ LogGroup 'Resolve version from manifest' { VersionPrefix = $versionPrefix Prerelease = $prerelease CreatePrerelease = $createPrerelease + GalleryVersion = $publishPSVersion ReleaseTag = $releaseTag PRNumber = $prNumber PRHeadRef = $prHeadRef @@ -160,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.' @@ -286,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 index 38613406..357a4ef2 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 @@ -11,6 +11,40 @@ BeforeAll { } 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' { @@ -90,5 +124,44 @@ Describe 'Publish-PSModule.Helpers' { $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 + } + } } }