From efbb135c513e014542817b6af401631d652e70ac Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 20:02:49 +0200 Subject: [PATCH 1/7] Refocus Publish-PSModule on Gallery publishing Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Publish-PSModule/action.yml | 27 +--- .../actions/Publish-PSModule/src/publish.ps1 | 138 +----------------- 2 files changed, 6 insertions(+), 159 deletions(-) diff --git a/.github/actions/Publish-PSModule/action.yml b/.github/actions/Publish-PSModule/action.yml index 50ab4eac..ea0a6cfb 100644 --- a/.github/actions/Publish-PSModule/action.yml +++ b/.github/actions/Publish-PSModule/action.yml @@ -1,5 +1,5 @@ name: Publish-PSModule -description: Publish a pre-versioned PowerShell module artifact to the PowerShell Gallery and GitHub Releases. +description: Publish a pre-versioned PowerShell module artifact to the PowerShell Gallery. author: PSModule inputs: @@ -14,36 +14,17 @@ inputs: description: PowerShell Gallery API Key. required: true 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: If specified, the action will only log the changes it would make, but will not publish the module. required: false default: 'false' WorkingDirectory: description: The working directory where the script will run from. required: false default: '.' - UsePRTitleAsReleaseName: - description: When enabled, uses the pull request title as the name for the GitHub release. If not set, the version string is used. - required: false - default: 'false' - UsePRBodyAsReleaseNotes: - description: When enabled, uses the pull request body as the release notes for the GitHub release. If not set, the release notes are auto-generated. - required: false - default: 'true' - UsePRTitleAsNotesHeading: - description: When enabled along with UsePRBodyAsReleaseNotes, the release notes will begin with the pull request title as a H1 heading followed by the pull request body. The title will reference the pull request number. - required: false - default: 'true' ArtifactName: 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 @@ -69,8 +50,4 @@ runs: PSMODULE_PUBLISH_PSMODULE_INPUT_ModulePath: ${{ inputs.ModulePath }} PSMODULE_PUBLISH_PSMODULE_INPUT_APIKey: ${{ inputs.APIKey }} PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf: ${{ inputs.WhatIf }} - 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.ps1 b/.github/actions/Publish-PSModule/src/publish.ps1 index 3c91c27c..13c6c92e 100644 --- a/.github/actions/Publish-PSModule/src/publish.ps1 +++ b/.github/actions/Publish-PSModule/src/publish.ps1 @@ -2,26 +2,10 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'apiKey', Justification = 'Variable is used in script blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'usePRBodyAsReleaseNotes', - Justification = 'Variable is used in script blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsReleaseName', - Justification = 'Variable is used in script blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsNotesHeading', - Justification = 'Variable is used in script blocks.' -)] [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'prNumber', Justification = 'Variable is used in script blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef', - Justification = 'Variable is used in script blocks.' -)] [CmdletBinding()] param() @@ -56,17 +40,10 @@ LogGroup 'Load inputs' { $modulePath = Resolve-Path -Path $modulePathCandidate | Select-Object -ExpandProperty Path $apiKey = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_APIKey $whatIf = $env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf -eq 'true' - $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 "Version prefix: [$versionPrefix]" - Write-Host "WhatIf: [$whatIf]" + Write-Host "Module name: [$name]" + Write-Host "Module path: [$modulePath]" + Write-Host "WhatIf: [$whatIf]" } #endregion Load inputs @@ -79,7 +56,6 @@ LogGroup 'Load PR information' { throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.' } $prNumber = $pull_request.number - $prHeadRef = $pull_request.head.ref } #endregion Load PR information @@ -134,25 +110,15 @@ 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]@{ ModuleVersion = $moduleVersion - VersionPrefix = $versionPrefix Prerelease = $prerelease CreatePrerelease = $createPrerelease GalleryVersion = $publishPSVersion - ReleaseTag = $releaseTag PRNumber = $prNumber - PRHeadRef = $prHeadRef } | Format-List | Out-String - - # Expose release tag to subsequent steps so cleanup can exclude the just-published tag. - "PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM } #endregion Resolve version from manifest @@ -194,100 +160,4 @@ LogGroup 'Publish to PSGallery' { } } #endregion Publish to PSGallery - -#region Create GitHub release with module artifact attached -# A zip of the published module is uploaded so the GitHub Release page exposes the exact bytes -# that were tested and pushed to the PowerShell Gallery. -LogGroup 'Create GitHub release' { - $releaseCreateCommand = @('release', 'create', $releaseTag) - $notesFilePath = $null - - if ($usePRTitleAsReleaseName -and $pull_request.title) { - $releaseCreateCommand += @('--title', $pull_request.title) - Write-Host "Using PR title as release name: [$($pull_request.title)]" - } else { - $releaseCreateCommand += @('--title', $releaseTag) - } - - # Build release notes content. Uses temp file to avoid escaping issues with special characters. - # Precedence rules for the three UsePR* parameters: - # 1. UsePRTitleAsNotesHeading + UsePRBodyAsReleaseNotes: Creates "# Title (#PR)\n\nBody" format. - # 2. UsePRBodyAsReleaseNotes only: Uses PR body as-is. - # 3. Fallback: Auto-generates notes via GitHub's --generate-notes. - if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) { - $notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)" - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR title as H1 heading with link and body as release notes' - } elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) { - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR body as release notes' - } else { - $releaseCreateCommand += @('--generate-notes') - } - - if ($createPrerelease) { - $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') - } - - if ($whatIf) { - Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" - $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" - } else { - try { - $releaseURL = gh @releaseCreateCommand - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create the release [$releaseTag]." - exit $LASTEXITCODE - } - } finally { - if ($notesFilePath -and (Test-Path -Path $notesFilePath)) { - Remove-Item -Path $notesFilePath -Force - } - } - } - - # Attach the built module as a release artifact so consumers can download the exact - # bytes that were tested and published to the PowerShell Gallery. - $zipFileName = "$name-$publishPSVersion.zip" - $zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipFileName - if (Test-Path -Path $zipPath) { - Remove-Item -Path $zipPath -Force - } - if ($whatIf) { - Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force" - Write-Host "WhatIf: gh release upload $releaseTag $zipPath --clobber" - } else { - Write-Host "Compressing module to [$zipPath]" - Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force - try { - gh release upload $releaseTag $zipPath --clobber - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to upload module artifact to release [$releaseTag]." - exit $LASTEXITCODE - } - Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName" - } finally { - if (Test-Path -Path $zipPath) { - Remove-Item -Path $zipPath -Force - } - } - } - - if ($whatIf) { - Write-Host "gh pr comment $prNumber -b '✅ $($releaseType): GitHub - $name $releaseTag'" - } else { - gh pr comment $prNumber -b "✅ $releaseType`: GitHub - [$name $releaseTag]($releaseURL)" - if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to comment on the pull request.' - exit $LASTEXITCODE - } - } - Write-Host "::notice title=✅ $releaseType`: GitHub - $name $releaseTag::$releaseURL" -} -#endregion Create GitHub release - -Write-Host "Publishing complete. PowerShell Gallery version: [$publishPSVersion]. GitHub release tag: [$releaseTag]." +Write-Host "Gallery publishing complete. Version: [$publishPSVersion]" From d23ba2c2e1fc54311155e9124d834baab9cf5f48 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 20:03:07 +0200 Subject: [PATCH 2/7] Add dedicated release action to publish workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Release-PSModule/action.yml | 60 +++++ .../actions/Release-PSModule/src/release.ps1 | 236 ++++++++++++++++++ .github/workflows/Publish-Module.yml | 14 +- 3 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 .github/actions/Release-PSModule/action.yml create mode 100644 .github/actions/Release-PSModule/src/release.ps1 diff --git a/.github/actions/Release-PSModule/action.yml b/.github/actions/Release-PSModule/action.yml new file mode 100644 index 00000000..31acec43 --- /dev/null +++ b/.github/actions/Release-PSModule/action.yml @@ -0,0 +1,60 @@ +name: Release-PSModule +description: Create a GitHub release from a pre-versioned PowerShell module artifact. +author: PSModule + +inputs: + Name: + description: Name of the module to release. + required: false + ModulePath: + description: Path to the folder containing the / module subdirectory from Build-PSModule. + required: false + default: outputs/module + ArtifactName: + description: Name of the uploaded artifact to download. Must match the name used in the upstream upload-artifact step. + required: false + default: module + WhatIf: + description: If specified, the action will only log the changes it would make, but will not actually create the release or upload artifacts. + required: false + default: 'false' + WorkingDirectory: + description: The working directory where the script will run from. + required: false + default: '.' + UsePRTitleAsReleaseName: + description: When enabled, uses the pull request title as the name for the GitHub release. If not set, the version string is used. + required: false + default: 'false' + UsePRBodyAsReleaseNotes: + description: When enabled, uses the pull request body as the release notes for the GitHub release. If not set, the release notes are auto-generated. + required: false + default: 'true' + UsePRTitleAsNotesHeading: + description: When enabled along with UsePRBodyAsReleaseNotes, the release notes will begin with the pull request title as a H1 heading followed by the pull request body. The title will reference the pull request number. + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Install-PSModule + uses: ./_wf/.github/actions/Install-PSModule + + - name: Download module artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ inputs.ArtifactName }} + path: ${{ inputs.ModulePath }} + + - name: Create GitHub release + shell: pwsh + working-directory: ${{ inputs.WorkingDirectory }} + env: + PSMODULE_RELEASE_PSMODULE_INPUT_Name: ${{ inputs.Name }} + PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath: ${{ inputs.ModulePath }} + PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf: ${{ inputs.WhatIf }} + PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes: ${{ inputs.UsePRBodyAsReleaseNotes }} + PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} + PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} + run: ${{ github.action_path }}/src/release.ps1 diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 new file mode 100644 index 00000000..4f02b605 --- /dev/null +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -0,0 +1,236 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'usePRBodyAsReleaseNotes', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsReleaseName', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsNotesHeading', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'prNumber', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef', + Justification = 'Variable is used in script blocks.' +)] +[CmdletBinding()] +param() + +$PSStyle.OutputRendering = 'Ansi' + +Import-Module -Name 'PSModule' -Force + +#region Load inputs +LogGroup 'Load inputs' { + $env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/' + + $name = if ([string]::IsNullOrEmpty($env:PSMODULE_RELEASE_PSMODULE_INPUT_Name)) { + $env:GITHUB_REPOSITORY_NAME + } else { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name + } + # Normalize to an absolute path anchored at the workspace root so that + # the resolved location agrees with where actions/download-artifact writes + # the artifact (workspace-root-relative), regardless of WorkingDirectory. + $modulePathInput = $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath + $modulePathCandidate = if ([System.IO.Path]::IsPathRooted($modulePathInput)) { + Join-Path -Path $modulePathInput -ChildPath $name + } else { + Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath $modulePathInput -AdditionalChildPath $name + } + if (-not (Test-Path -Path $modulePathCandidate -PathType Container)) { + Write-Error ("Module directory not found at [$modulePathCandidate]. " + + 'Ensure the artifact contains a // subdirectory layout.') + exit 1 + } + $modulePath = Resolve-Path -Path $modulePathCandidate | Select-Object -ExpandProperty Path + $whatIf = $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf -eq 'true' + $usePRBodyAsReleaseNotes = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes -eq 'true' + $usePRTitleAsReleaseName = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true' + $usePRTitleAsNotesHeading = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true' + + Write-Host "Module name: [$name]" + Write-Host "Module path: [$modulePath]" + Write-Host "WhatIf: [$whatIf]" +} +#endregion Load inputs + +#region Load PR information +LogGroup 'Load PR information' { + $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH + $githubEvent = $githubEventJson | ConvertFrom-Json + $pull_request = $githubEvent.pull_request + if (-not $pull_request) { + throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.' + } + $prNumber = $pull_request.number + $prHeadRef = $pull_request.head.ref +} +#endregion Load PR information + +#region Resolve version from manifest +LogGroup 'Resolve version from manifest' { + Add-PSModulePath -Path (Split-Path -Path $modulePath -Parent) + $manifestFilePath = Join-Path -Path $modulePath -ChildPath "$name.psd1" + Write-Host "Module manifest file path: [$manifestFilePath]" + if (-not (Test-Path -Path $manifestFilePath)) { + Write-Error "Module manifest file not found at [$manifestFilePath]" + exit 1 + } + + Show-FileContent -Path $manifestFilePath + + $manifest = Test-ModuleManifest -Path $manifestFilePath -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + if ($manifest) { + Write-Host "Manifest validated: [$($manifest.Name)] v[$($manifest.Version)]" + } else { + Write-Host '::warning::Test-ModuleManifest returned warnings (e.g. unresolved RequiredModules). Continuing with data-file validation.' + } + + try { + $manifestData = Import-PowerShellDataFile -Path $manifestFilePath + } catch { + Write-Error "Failed to import manifest data file [$manifestFilePath]: $($_.Exception.Message)" + exit 1 + } + $moduleVersion = $manifestData.ModuleVersion + if (-not ($moduleVersion -match '^\d+\.\d+\.\d+$')) { + Write-Error ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " + + 'Ensure Build-PSModule has stamped the artifact with a final version.') + exit 1 + } + if ($moduleVersion -eq '999.0.0') { + Write-Error ('ModuleVersion is the placeholder [999.0.0]. ' + + 'The artifact was not stamped with a real version by the build step.') + exit 1 + } + $prerelease = $manifestData.PrivateData.PSData.Prerelease + if ([string]::IsNullOrWhiteSpace($prerelease)) { + $prerelease = '' + $createPrerelease = $false + } else { + if ($prerelease -notmatch '^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$') { + Write-Error ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " + + 'It must contain only alphanumerics, hyphens, and dots as separators.') + exit 1 + } + $createPrerelease = $true + } + + $releaseTag = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } + $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } + $publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } + + [PSCustomObject]@{ + ModuleVersion = $moduleVersion + Prerelease = $prerelease + CreatePrerelease = $createPrerelease + ReleaseTag = $releaseTag + PRNumber = $prNumber + PRHeadRef = $prHeadRef + } | Format-List | Out-String + + # Expose release tag to subsequent steps so cleanup can exclude the just-created tag. + "PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM +} +#endregion Resolve version from manifest + +#region Create GitHub release with module artifact attached +# A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes. +LogGroup 'Create GitHub release' { + $releaseCreateCommand = @('release', 'create', $releaseTag) + $notesFilePath = $null + + if ($usePRTitleAsReleaseName -and $pull_request.title) { + $releaseCreateCommand += @('--title', $pull_request.title) + Write-Host "Using PR title as release name: [$($pull_request.title)]" + } else { + $releaseCreateCommand += @('--title', $releaseTag) + } + + # Build release notes content. Uses temp file to avoid escaping issues with special characters. + # Precedence rules for the three UsePR* parameters: + # 1. UsePRTitleAsNotesHeading + UsePRBodyAsReleaseNotes: Creates "# Title (#PR)\n\nBody" format. + # 2. UsePRBodyAsReleaseNotes only: Uses PR body as-is. + # 3. Fallback: Auto-generates notes via GitHub's --generate-notes. + if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) { + $notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)" + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR title as H1 heading with link and body as release notes' + } elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) { + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR body as release notes' + } else { + $releaseCreateCommand += @('--generate-notes') + } + + if ($createPrerelease) { + $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') + } + + if ($whatIf) { + Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" + $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" + } else { + try { + $releaseURL = gh @releaseCreateCommand + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to create the release [$releaseTag]." + exit $LASTEXITCODE + } + } finally { + if ($notesFilePath -and (Test-Path -Path $notesFilePath)) { + Remove-Item -Path $notesFilePath -Force + } + } + } + + # Attach the built module as a release artifact so consumers can download the exact bytes. + $zipFileName = "$name-$publishPSVersion.zip" + $zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipFileName + if (Test-Path -Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + if ($whatIf) { + Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force" + Write-Host "WhatIf: gh release upload $releaseTag $zipPath --clobber" + } else { + Write-Host "Compressing module to [$zipPath]" + Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force + try { + gh release upload $releaseTag $zipPath --clobber + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to upload module artifact to release [$releaseTag]." + exit $LASTEXITCODE + } + Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName" + } finally { + if (Test-Path -Path $zipPath) { + Remove-Item -Path $zipPath -Force + } + } + } + + if ($whatIf) { + Write-Host "gh pr comment $prNumber -b '✅ $($releaseType): GitHub - $name $releaseTag'" + } else { + gh pr comment $prNumber -b "✅ $releaseType`: GitHub - [$name $releaseTag]($releaseURL)" + if ($LASTEXITCODE -ne 0) { + Write-Error 'Failed to comment on the pull request.' + exit $LASTEXITCODE + } + } + Write-Host "::notice title=✅ $releaseType`: GitHub - $name $releaseTag::$releaseURL" +} +#endregion Create GitHub release + +Write-Host "Release creation complete. Version: [$releaseTag]" diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 6ff7ad62..4f2b93e0 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -46,6 +46,18 @@ jobs: ModulePath: outputs/module APIKey: ${{ secrets.APIKey }} WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} + WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} + + - name: Create GitHub release + if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' + uses: ./_wf/.github/actions/Release-PSModule + env: + GH_TOKEN: ${{ github.token }} + with: + Name: ${{ fromJson(inputs.Settings).Name }} + ModulePath: outputs/module + ArtifactName: module + WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} UsePRTitleAsReleaseName: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsReleaseName }} UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }} UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }} @@ -53,7 +65,7 @@ jobs: WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Cleanup prereleases - if: fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' + if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' uses: ./_wf/.github/actions/Cleanup-PSModulePrereleases env: GH_TOKEN: ${{ github.token }} From 3bbf103662f30efb67fa060b142f19dacf3bfca3 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Thu, 23 Jul 2026 20:15:30 +0200 Subject: [PATCH 3/7] Suppress scriptblock variable analyzer noise Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Release-PSModule/src/release.ps1 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 4f02b605..db75abb1 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -18,6 +18,14 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef', Justification = 'Variable is used in script blocks.' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'releaseType', + Justification = 'Variable is used in script blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', 'publishPSVersion', + Justification = 'Variable is used in script blocks.' +)] [CmdletBinding()] param() From 8e697e3b408b92657c86a0b5ad8f8bef70be2844 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 12:41:15 +0200 Subject: [PATCH 4/7] Use resolved release tags in publish workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Cleanup-PSModulePrereleases/action.yml | 2 +- .../src/Publish-PSModule.Helpers.psm1 | 59 --------- .../tests/Publish-PSModule.Helpers.Tests.ps1 | 119 ------------------ .github/actions/Release-PSModule/action.yml | 4 + .../actions/Release-PSModule/src/release.ps1 | 13 +- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 90 +++++++++++++ .github/workflows/Publish-Module.yml | 9 +- 7 files changed, 109 insertions(+), 187 deletions(-) create mode 100644 .github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 diff --git a/.github/actions/Cleanup-PSModulePrereleases/action.yml b/.github/actions/Cleanup-PSModulePrereleases/action.yml index 3c076b45..44c3f37d 100644 --- a/.github/actions/Cleanup-PSModulePrereleases/action.yml +++ b/.github/actions/Cleanup-PSModulePrereleases/action.yml @@ -28,5 +28,5 @@ runs: working-directory: ${{ inputs.WorkingDirectory }} env: PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf: ${{ inputs.WhatIf }} - PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ env.PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag }} + PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ env.PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag }} run: ${{ github.action_path }}/src/cleanup.ps1 diff --git a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 index b7fe3253..da6aa7f8 100644 --- a/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 +++ b/.github/actions/Publish-PSModule/src/Publish-PSModule.Helpers.psm1 @@ -43,62 +43,3 @@ "$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/tests/Publish-PSModule.Helpers.Tests.ps1 b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 index 357a4ef2..59a2f197 100644 --- a/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 +++ b/.github/actions/Publish-PSModule/tests/Publish-PSModule.Helpers.Tests.ps1 @@ -45,123 +45,4 @@ 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 - } - } - - # 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/actions/Release-PSModule/action.yml b/.github/actions/Release-PSModule/action.yml index 31acec43..d0b00438 100644 --- a/.github/actions/Release-PSModule/action.yml +++ b/.github/actions/Release-PSModule/action.yml @@ -34,6 +34,9 @@ inputs: description: When enabled along with UsePRBodyAsReleaseNotes, the release notes will begin with the pull request title as a H1 heading followed by the pull request body. The title will reference the pull request number. required: false default: 'true' + ReleaseTag: + description: Full GitHub release tag resolved by Resolve-PSModuleVersion. + required: true runs: using: composite @@ -57,4 +60,5 @@ runs: PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes: ${{ inputs.UsePRBodyAsReleaseNotes }} PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }} PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }} + PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag: ${{ inputs.ReleaseTag }} run: ${{ github.action_path }}/src/release.ps1 diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index db75abb1..3dd924ea 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -22,10 +22,6 @@ 'PSUseDeclaredVarsMoreThanAssignments', 'releaseType', Justification = 'Variable is used in script blocks.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', 'publishPSVersion', - Justification = 'Variable is used in script blocks.' -)] [CmdletBinding()] param() @@ -61,9 +57,14 @@ LogGroup 'Load inputs' { $usePRBodyAsReleaseNotes = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes -eq 'true' $usePRTitleAsReleaseName = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true' $usePRTitleAsNotesHeading = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true' + $releaseTag = $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag + if ([string]::IsNullOrWhiteSpace($releaseTag)) { + throw 'ReleaseTag is required. Ensure Publish.Module.Resolution.FullVersion is passed from the Plan job.' + } Write-Host "Module name: [$name]" Write-Host "Module path: [$modulePath]" + Write-Host "Release tag: [$releaseTag]" Write-Host "WhatIf: [$whatIf]" } #endregion Load inputs @@ -130,7 +131,6 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $true } - $releaseTag = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } $publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } @@ -138,13 +138,14 @@ LogGroup 'Resolve version from manifest' { ModuleVersion = $moduleVersion Prerelease = $prerelease CreatePrerelease = $createPrerelease + GalleryVersion = $publishPSVersion ReleaseTag = $releaseTag PRNumber = $prNumber PRHeadRef = $prHeadRef } | Format-List | Out-String # Expose release tag to subsequent steps so cleanup can exclude the just-created tag. - "PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM + "PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM } #endregion Resolve version from manifest diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 new file mode 100644 index 00000000..577bcfb2 --- /dev/null +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -0,0 +1,90 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Variables are assigned in BeforeAll and used inside It blocks.' +)] +[CmdletBinding()] +param() + +BeforeAll { + Import-Module -Name 'PSModule' -Force + + $script:releaseScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/release.ps1' + $script:environmentVariableNames = @( + 'GITHUB_ENV' + 'GITHUB_EVENT_PATH' + 'GITHUB_REPOSITORY' + 'GITHUB_WORKSPACE' + 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath' + 'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName' + 'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading' + 'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag' + ) + $script:originalEnvironment = @{} + foreach ($name in $script:environmentVariableNames) { + $script:originalEnvironment[$name] = [System.Environment]::GetEnvironmentVariable($name) + } +} + +AfterAll { + foreach ($name in $script:environmentVariableNames) { + [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) + } +} + +Describe 'Release-PSModule WhatIf' { + It 'creates a prefixed prerelease tag without GitHub side effects' { + $moduleName = 'TestModule' + $workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace' + $modulePath = Join-Path -Path $workspacePath -ChildPath "outputs/module/$moduleName" + $manifestPath = Join-Path -Path $modulePath -ChildPath "$moduleName.psd1" + $eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json' + $githubEnvironmentPath = Join-Path -Path $TestDrive -ChildPath 'github-environment' + + New-Item -Path $modulePath -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path -Path $modulePath -ChildPath "$moduleName.psm1") -Value '' + Set-Content -Path $manifestPath -Value @" +@{ + RootModule = '$moduleName.psm1' + ModuleVersion = '1.2.3' + GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' + Author = 'PSModule' + Description = 'Test module' + PowerShellVersion = '5.1' + PrivateData = @{ + PSData = @{ + Prerelease = 'preview.1' + } + } +} +"@ + @{ + pull_request = @{ + number = 42 + title = 'Create test release' + body = 'Release notes' + head = @{ + ref = 'feature/release' + } + } + } | ConvertTo-Json -Depth 5 | Set-Content -Path $eventPath + + $env:GITHUB_ENV = $githubEnvironmentPath + $env:GITHUB_EVENT_PATH = $eventPath + $env:GITHUB_REPOSITORY = "PSModule/$moduleName" + $env:GITHUB_WORKSPACE = $workspacePath + $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $moduleName + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = 'outputs/module' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'true' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'true' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'true' + $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1' + + { & $script:releaseScriptPath } | Should -Not -Throw + + Get-Content -Path $githubEnvironmentPath | Should -Contain 'PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=v1.2.3-preview.1' + } +} diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index 4f2b93e0..a3ddf615 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -49,6 +49,7 @@ jobs: WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Create GitHub release + id: Create-GitHub-Release if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' uses: ./_wf/.github/actions/Release-PSModule env: @@ -61,11 +62,15 @@ 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 }} + ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Cleanup prereleases - if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' + if: >- + always() && !cancelled() && + fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' && + (fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType == 'None' || + steps.Create-GitHub-Release.outcome == 'success') uses: ./_wf/.github/actions/Cleanup-PSModulePrereleases env: GH_TOKEN: ${{ github.token }} From e1a2ac54ae8a5f739bba763b7957a77f443c6aba Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 12:47:04 +0200 Subject: [PATCH 5/7] Align release action test fixture Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index 577bcfb2..a315be64 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -63,9 +63,9 @@ Describe 'Release-PSModule WhatIf' { @{ pull_request = @{ number = 42 - title = 'Create test release' - body = 'Release notes' - head = @{ + title = 'Create test release' + body = 'Release notes' + head = @{ ref = 'feature/release' } } From 034d5aa2ab91ce789ed5838e004b25eb39ba6845 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 13:15:34 +0200 Subject: [PATCH 6/7] Make GitHub release retries resumable Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Cleanup-PSModulePrereleases/action.yml | 6 +- .github/actions/Release-PSModule/action.yml | 9 + .../actions/Release-PSModule/src/release.ps1 | 166 ++++++++++-------- .../tests/Release-PSModule.WhatIf.Tests.ps1 | 65 +++++-- .github/workflows/Publish-Module.yml | 14 +- 5 files changed, 165 insertions(+), 95 deletions(-) diff --git a/.github/actions/Cleanup-PSModulePrereleases/action.yml b/.github/actions/Cleanup-PSModulePrereleases/action.yml index 44c3f37d..0e04885e 100644 --- a/.github/actions/Cleanup-PSModulePrereleases/action.yml +++ b/.github/actions/Cleanup-PSModulePrereleases/action.yml @@ -7,6 +7,10 @@ inputs: description: Control whether to automatically delete prerelease tags. required: false default: 'true' + ReleaseTag: + description: GitHub release tag to retain during cleanup. + required: false + default: '' WhatIf: description: If specified, the action will only log the changes it would make. required: false @@ -28,5 +32,5 @@ runs: working-directory: ${{ inputs.WorkingDirectory }} env: PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf: ${{ inputs.WhatIf }} - PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ env.PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag }} + PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ inputs.ReleaseTag }} run: ${{ github.action_path }}/src/cleanup.ps1 diff --git a/.github/actions/Release-PSModule/action.yml b/.github/actions/Release-PSModule/action.yml index d0b00438..d83f18da 100644 --- a/.github/actions/Release-PSModule/action.yml +++ b/.github/actions/Release-PSModule/action.yml @@ -38,6 +38,14 @@ inputs: description: Full GitHub release tag resolved by Resolve-PSModuleVersion. required: true +outputs: + ReleaseTag: + description: Full GitHub release tag created or resumed by this action. + value: ${{ steps.release.outputs.ReleaseTag }} + ReleaseUrl: + description: URL of the GitHub release created or resumed by this action. + value: ${{ steps.release.outputs.ReleaseUrl }} + runs: using: composite steps: @@ -51,6 +59,7 @@ runs: path: ${{ inputs.ModulePath }} - name: Create GitHub release + id: release shell: pwsh working-directory: ${{ inputs.WorkingDirectory }} env: diff --git a/.github/actions/Release-PSModule/src/release.ps1 b/.github/actions/Release-PSModule/src/release.ps1 index 3dd924ea..652d5d78 100644 --- a/.github/actions/Release-PSModule/src/release.ps1 +++ b/.github/actions/Release-PSModule/src/release.ps1 @@ -1,4 +1,26 @@ -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( +#Requires -Version 7.0 +#Requires -Modules PSModule + +<# + .SYNOPSIS + Creates or resumes a GitHub release for a versioned PowerShell module artifact. + + .DESCRIPTION + Validates the supplied module artifact, creates a GitHub release when its tag does not + already exist, uploads the artifact ZIP, and reports the result on the source pull request. + + .EXAMPLE + ./release.ps1 + + Creates or resumes the release using inputs supplied by the Release-PSModule action. + + .INPUTS + None. The Release-PSModule composite action supplies values through environment variables. + + .OUTPUTS + None. The script writes ReleaseTag and ReleaseUrl to the GitHub Actions output file. +#> +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', 'usePRBodyAsReleaseNotes', Justification = 'Variable is used in script blocks.' )] @@ -25,11 +47,11 @@ [CmdletBinding()] param() +$ErrorActionPreference = 'Stop' $PSStyle.OutputRendering = 'Ansi' Import-Module -Name 'PSModule' -Force -#region Load inputs LogGroup 'Load inputs' { $env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/' @@ -67,9 +89,7 @@ LogGroup 'Load inputs' { Write-Host "Release tag: [$releaseTag]" Write-Host "WhatIf: [$whatIf]" } -#endregion Load inputs -#region Load PR information LogGroup 'Load PR information' { $githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH $githubEvent = $githubEventJson | ConvertFrom-Json @@ -80,9 +100,7 @@ LogGroup 'Load PR information' { $prNumber = $pull_request.number $prHeadRef = $pull_request.head.ref } -#endregion Load PR information -#region Resolve version from manifest LogGroup 'Resolve version from manifest' { Add-PSModulePath -Path (Split-Path -Path $modulePath -Parent) $manifestFilePath = Join-Path -Path $modulePath -ChildPath "$name.psd1" @@ -101,22 +119,15 @@ LogGroup 'Resolve version from manifest' { Write-Host '::warning::Test-ModuleManifest returned warnings (e.g. unresolved RequiredModules). Continuing with data-file validation.' } - try { - $manifestData = Import-PowerShellDataFile -Path $manifestFilePath - } catch { - Write-Error "Failed to import manifest data file [$manifestFilePath]: $($_.Exception.Message)" - exit 1 - } + $manifestData = Import-PowerShellDataFile -Path $manifestFilePath -ErrorAction Stop $moduleVersion = $manifestData.ModuleVersion if (-not ($moduleVersion -match '^\d+\.\d+\.\d+$')) { - Write-Error ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " + + throw ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " + 'Ensure Build-PSModule has stamped the artifact with a final version.') - exit 1 } if ($moduleVersion -eq '999.0.0') { - Write-Error ('ModuleVersion is the placeholder [999.0.0]. ' + + throw ('ModuleVersion is the placeholder [999.0.0]. ' + 'The artifact was not stamped with a real version by the build step.') - exit 1 } $prerelease = $manifestData.PrivateData.PSData.Prerelease if ([string]::IsNullOrWhiteSpace($prerelease)) { @@ -124,9 +135,8 @@ LogGroup 'Resolve version from manifest' { $createPrerelease = $false } else { if ($prerelease -notmatch '^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$') { - Write-Error ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " + + throw ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " + 'It must contain only alphanumerics, hyphens, and dots as separators.') - exit 1 } $createPrerelease = $true } @@ -134,67 +144,76 @@ LogGroup 'Resolve version from manifest' { $releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' } $publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion } - [PSCustomObject]@{ - ModuleVersion = $moduleVersion - Prerelease = $prerelease - CreatePrerelease = $createPrerelease - GalleryVersion = $publishPSVersion - ReleaseTag = $releaseTag - PRNumber = $prNumber - PRHeadRef = $prHeadRef - } | Format-List | Out-String - - # Expose release tag to subsequent steps so cleanup can exclude the just-created tag. - "PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM + Write-Host ( + [PSCustomObject]@{ + ModuleVersion = $moduleVersion + Prerelease = $prerelease + CreatePrerelease = $createPrerelease + GalleryVersion = $publishPSVersion + ReleaseTag = $releaseTag + PRNumber = $prNumber + PRHeadRef = $prHeadRef + } | Format-List | Out-String + ) } -#endregion Resolve version from manifest -#region Create GitHub release with module artifact attached # A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes. LogGroup 'Create GitHub release' { $releaseCreateCommand = @('release', 'create', $releaseTag) $notesFilePath = $null + $releaseExists = $false - if ($usePRTitleAsReleaseName -and $pull_request.title) { - $releaseCreateCommand += @('--title', $pull_request.title) - Write-Host "Using PR title as release name: [$($pull_request.title)]" - } else { - $releaseCreateCommand += @('--title', $releaseTag) - } - - # Build release notes content. Uses temp file to avoid escaping issues with special characters. - # Precedence rules for the three UsePR* parameters: - # 1. UsePRTitleAsNotesHeading + UsePRBodyAsReleaseNotes: Creates "# Title (#PR)\n\nBody" format. - # 2. UsePRBodyAsReleaseNotes only: Uses PR body as-is. - # 3. Fallback: Auto-generates notes via GitHub's --generate-notes. - if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) { - $notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)" - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR title as H1 heading with link and body as release notes' - } elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) { - $notesFilePath = [System.IO.Path]::GetTempFileName() - Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8 - $releaseCreateCommand += @('--notes-file', $notesFilePath) - Write-Host 'Using PR body as release notes' - } else { - $releaseCreateCommand += @('--generate-notes') - } + if (-not $whatIf) { + $encodedReleaseTag = [System.Uri]::EscapeDataString($releaseTag) + $releaseLookupResult = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$encodedReleaseTag" --jq '.html_url' 2>&1 + $releaseLookupExitCode = $LASTEXITCODE - if ($createPrerelease) { - $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') + if ($releaseLookupExitCode -eq 0) { + $releaseURL = ($releaseLookupResult | Out-String).Trim() + $releaseExists = $true + Write-Host "GitHub release [$releaseTag] already exists. Resuming its artifact upload." + } elseif (($releaseLookupResult | Out-String) -notmatch 'HTTP 404') { + throw "Unable to determine whether release [$releaseTag] exists: $($releaseLookupResult | Out-String)" + } } - if ($whatIf) { - Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" - $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" - } else { + if (-not $releaseExists) { + if ($usePRTitleAsReleaseName -and $pull_request.title) { + $releaseCreateCommand += @('--title', $pull_request.title) + Write-Host "Using PR title as release name: [$($pull_request.title)]" + } else { + $releaseCreateCommand += @('--title', $releaseTag) + } + + # Build release notes content. Uses a file to preserve special characters. + if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) { + $notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)" + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $notes -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR title as H1 heading with link and body as release notes' + } elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) { + $notesFilePath = [System.IO.Path]::GetTempFileName() + Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8 + $releaseCreateCommand += @('--notes-file', $notesFilePath) + Write-Host 'Using PR body as release notes' + } else { + $releaseCreateCommand += @('--generate-notes') + } + + if ($createPrerelease) { + $releaseCreateCommand += @('--target', $prHeadRef, '--prerelease') + } + try { - $releaseURL = gh @releaseCreateCommand - if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to create the release [$releaseTag]." - exit $LASTEXITCODE + if ($whatIf) { + Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')" + $releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag" + } else { + $releaseURL = gh @releaseCreateCommand + if ($LASTEXITCODE -ne 0) { + throw "Failed to create the release [$releaseTag]." + } } } finally { if ($notesFilePath -and (Test-Path -Path $notesFilePath)) { @@ -218,8 +237,7 @@ LogGroup 'Create GitHub release' { try { gh release upload $releaseTag $zipPath --clobber if ($LASTEXITCODE -ne 0) { - Write-Error "Failed to upload module artifact to release [$releaseTag]." - exit $LASTEXITCODE + throw "Failed to upload module artifact to release [$releaseTag]." } Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName" } finally { @@ -234,12 +252,14 @@ LogGroup 'Create GitHub release' { } else { gh pr comment $prNumber -b "✅ $releaseType`: GitHub - [$name $releaseTag]($releaseURL)" if ($LASTEXITCODE -ne 0) { - Write-Error 'Failed to comment on the pull request.' - exit $LASTEXITCODE + throw 'Failed to comment on the pull request.' } } + if ($env:GITHUB_OUTPUT) { + "ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_OUTPUT -Append -Encoding utf8NoBOM + "ReleaseUrl=$releaseURL" | Out-File -Path $env:GITHUB_OUTPUT -Append -Encoding utf8NoBOM + } Write-Host "::notice title=✅ $releaseType`: GitHub - $name $releaseTag::$releaseURL" } -#endregion Create GitHub release Write-Host "Release creation complete. Version: [$releaseTag]" diff --git a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 index a315be64..3c928b0f 100644 --- a/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 +++ b/.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1 @@ -10,10 +10,11 @@ BeforeAll { $script:releaseScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/release.ps1' $script:environmentVariableNames = @( - 'GITHUB_ENV' 'GITHUB_EVENT_PATH' + 'GITHUB_OUTPUT' 'GITHUB_REPOSITORY' 'GITHUB_WORKSPACE' + 'RELEASE_ACTION_GH_CALL_LOG' 'PSMODULE_RELEASE_PSMODULE_INPUT_Name' 'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath' 'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf' @@ -32,22 +33,26 @@ AfterAll { foreach ($name in $script:environmentVariableNames) { [System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name]) } + Remove-Item -Path function:global:gh -ErrorAction SilentlyContinue } Describe 'Release-PSModule WhatIf' { - It 'creates a prefixed prerelease tag without GitHub side effects' { - $moduleName = 'TestModule' - $workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace' - $modulePath = Join-Path -Path $workspacePath -ChildPath "outputs/module/$moduleName" - $manifestPath = Join-Path -Path $modulePath -ChildPath "$moduleName.psd1" + BeforeEach { + $script:moduleName = 'TestModule' + $script:workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace' + $script:modulePath = Join-Path -Path $script:workspacePath -ChildPath "outputs/module/$script:moduleName" + $manifestPath = Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psd1" $eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json' - $githubEnvironmentPath = Join-Path -Path $TestDrive -ChildPath 'github-environment' + $script:githubOutputPath = Join-Path -Path $TestDrive -ChildPath 'github-output' + $script:ghCallLogPath = Join-Path -Path $TestDrive -ChildPath 'gh-calls' - New-Item -Path $modulePath -ItemType Directory -Force | Out-Null - Set-Content -Path (Join-Path -Path $modulePath -ChildPath "$moduleName.psm1") -Value '' + $null = New-Item -Path $script:modulePath -ItemType Directory -Force + $null = New-Item -Path $script:githubOutputPath -ItemType File -Force + $null = New-Item -Path $script:ghCallLogPath -ItemType File -Force + Set-Content -Path (Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psm1") -Value '' Set-Content -Path $manifestPath -Value @" @{ - RootModule = '$moduleName.psm1' + RootModule = '$script:moduleName.psm1' ModuleVersion = '1.2.3' GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4' Author = 'PSModule' @@ -71,20 +76,50 @@ Describe 'Release-PSModule WhatIf' { } } | ConvertTo-Json -Depth 5 | Set-Content -Path $eventPath - $env:GITHUB_ENV = $githubEnvironmentPath $env:GITHUB_EVENT_PATH = $eventPath - $env:GITHUB_REPOSITORY = "PSModule/$moduleName" - $env:GITHUB_WORKSPACE = $workspacePath - $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $moduleName + $env:GITHUB_OUTPUT = $script:githubOutputPath + $env:GITHUB_REPOSITORY = "PSModule/$script:moduleName" + $env:GITHUB_WORKSPACE = $script:workspacePath + $env:RELEASE_ACTION_GH_CALL_LOG = $script:ghCallLogPath + $env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $script:moduleName $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = 'outputs/module' $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'true' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'true' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false' $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'true' $env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1' + } + + It 'creates a prefixed prerelease tag without GitHub side effects' { + { & $script:releaseScriptPath } | Should -Not -Throw + + Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1' + Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseUrl=https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' + } + + It 'resumes an existing release after a partial failure' { + $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false' + Set-Item -Path function:global:gh -Value { + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments)] + [string[]] $Arguments + ) + + Add-Content -Path $env:RELEASE_ACTION_GH_CALL_LOG -Value ($Arguments -join ' ') + $global:LASTEXITCODE = 0 + if ($Arguments[0] -eq 'api') { + return 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1' + } + } { & $script:releaseScriptPath } | Should -Not -Throw - Get-Content -Path $githubEnvironmentPath | Should -Contain 'PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=v1.2.3-preview.1' + $ghCalls = Get-Content -Path $script:ghCallLogPath -Raw + $ghCalls | Should -Match 'api repos/PSModule/TestModule/releases/tags/v1.2.3-preview.1 --jq .html_url' + $ghCalls | Should -Match 'release upload v1.2.3-preview.1' + $ghCalls | Should -Match 'pr comment 42' + $ghCalls | Should -Not -Match 'release create' + Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1' } } diff --git a/.github/workflows/Publish-Module.yml b/.github/workflows/Publish-Module.yml index a3ddf615..f02f2268 100644 --- a/.github/workflows/Publish-Module.yml +++ b/.github/workflows/Publish-Module.yml @@ -12,14 +12,15 @@ on: description: The complete settings object. required: true -permissions: - contents: write # to checkout the repo, create releases, and upload release artifacts - pull-requests: write # to comment on PRs +permissions: {} jobs: Publish-Module: name: Publish-Module - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 + permissions: + contents: write # Create releases and upload release artifacts. + pull-requests: write # Comment on pull requests. env: SETTINGS: ${{ inputs.Settings }} steps: @@ -49,7 +50,7 @@ jobs: WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} - name: Create GitHub release - id: Create-GitHub-Release + id: create-github-release if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None' uses: ./_wf/.github/actions/Release-PSModule env: @@ -70,11 +71,12 @@ jobs: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease' && (fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType == 'None' || - steps.Create-GitHub-Release.outcome == 'success') + steps.create-github-release.outcome == 'success') uses: ./_wf/.github/actions/Cleanup-PSModulePrereleases env: GH_TOKEN: ${{ github.token }} with: WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }} AutoCleanup: ${{ fromJson(inputs.Settings).Publish.Module.AutoCleanup }} + ReleaseTag: ${{ steps.create-github-release.outputs.ReleaseTag }} WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }} From d59f1fa7332fb7e01a0d73c937c8dba41b962571 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 9 Aug 2026 13:15:39 +0200 Subject: [PATCH 7/7] Document separated publishing actions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/Publish-PSModule/README.md | 32 +++++++++++++++++ .github/actions/Release-PSModule/README.md | 41 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 .github/actions/Publish-PSModule/README.md create mode 100644 .github/actions/Release-PSModule/README.md diff --git a/.github/actions/Publish-PSModule/README.md b/.github/actions/Publish-PSModule/README.md new file mode 100644 index 00000000..1a24f4f4 --- /dev/null +++ b/.github/actions/Publish-PSModule/README.md @@ -0,0 +1,32 @@ +# Publish-PSModule + +Publishes a pre-versioned PowerShell module artifact to the PowerShell Gallery. GitHub Release creation is intentionally handled by the separate [Release-PSModule](../Release-PSModule/README.md) action. + +## Inputs + +| Name | Description | Required | Default | +| --- | --- | --- | --- | +| `Name` | Name of the module to publish. | No | Repository name | +| `ModulePath` | Path containing the built `/` module directory. | No | `outputs/module` | +| `ArtifactName` | Name of the module artifact to download. | No | `module` | +| `APIKey` | PowerShell Gallery API key. | Yes | N/A | +| `WhatIf` | Logs publishing operations without publishing the module. | No | `false` | +| `WorkingDirectory` | Directory where the publishing script runs. | No | `.` | + +## Outputs + +This action does not provide outputs. + +## Usage + +```yaml +- name: Publish module + uses: ./.github/actions/Publish-PSModule + with: + Name: ExampleModule + ModulePath: outputs/module + ArtifactName: module + APIKey: ${{ secrets.APIKEY }} +``` + +Use [Release-PSModule](../Release-PSModule/README.md) in a separate workflow step to create the GitHub release from the same artifact. diff --git a/.github/actions/Release-PSModule/README.md b/.github/actions/Release-PSModule/README.md new file mode 100644 index 00000000..770d190b --- /dev/null +++ b/.github/actions/Release-PSModule/README.md @@ -0,0 +1,41 @@ +# Release-PSModule + +Creates or resumes a GitHub release for a pre-versioned PowerShell module artifact. The action attaches the exact built module ZIP to the release and reports the result on the source pull request. + +## Inputs + +| Name | Description | Required | Default | +| --- | --- | --- | --- | +| `Name` | Name of the module to release. | No | Repository name | +| `ModulePath` | Path containing the built `/` module directory. | No | `outputs/module` | +| `ArtifactName` | Name of the module artifact to download. | No | `module` | +| `ReleaseTag` | Full GitHub release tag resolved by `Resolve-PSModuleVersion`. | Yes | N/A | +| `WhatIf` | Logs release operations without creating or uploading anything. | No | `false` | +| `WorkingDirectory` | Directory where the release script runs. | No | `.` | +| `UsePRTitleAsReleaseName` | Uses the pull request title as the release name. | No | `false` | +| `UsePRBodyAsReleaseNotes` | Uses the pull request body as release notes. | No | `true` | +| `UsePRTitleAsNotesHeading` | Adds the pull request title as an H1 release-notes heading. | No | `true` | + +## Outputs + +| Name | Description | +| --- | --- | +| `ReleaseTag` | Full GitHub release tag created or resumed by the action. | +| `ReleaseUrl` | URL of the GitHub release created or resumed by the action. | + +## Usage + +Pass the Plan job's resolved `FullVersion` as `ReleaseTag` so the configured tag prefix is preserved. + +```yaml +- name: Create GitHub release + id: create-github-release + uses: ./.github/actions/Release-PSModule + with: + Name: ExampleModule + ModulePath: outputs/module + ArtifactName: module + ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }} +``` + +The action requires a `pull_request` event and a built module artifact. If a release already exists for `ReleaseTag`, it resumes by replacing the module ZIP, which makes retrying a partial release safe.