Skip to content

Commit 99c0d97

Browse files
Add dedicated release action to publish workflow
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 37ab2dd commit 99c0d97

3 files changed

Lines changed: 309 additions & 1 deletion

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Release-PSModule
2+
description: Create a GitHub release from a pre-versioned PowerShell module artifact.
3+
author: PSModule
4+
5+
inputs:
6+
Name:
7+
description: Name of the module to release.
8+
required: false
9+
ModulePath:
10+
description: Path to the folder containing the <Name>/ module subdirectory from Build-PSModule.
11+
required: false
12+
default: outputs/module
13+
ArtifactName:
14+
description: Name of the uploaded artifact to download. Must match the name used in the upstream upload-artifact step.
15+
required: false
16+
default: module
17+
WhatIf:
18+
description: If specified, the action will only log the changes it would make, but will not actually create the release or upload artifacts.
19+
required: false
20+
default: 'false'
21+
WorkingDirectory:
22+
description: The working directory where the script will run from.
23+
required: false
24+
default: '.'
25+
UsePRTitleAsReleaseName:
26+
description: When enabled, uses the pull request title as the name for the GitHub release. If not set, the version string is used.
27+
required: false
28+
default: 'false'
29+
UsePRBodyAsReleaseNotes:
30+
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.
31+
required: false
32+
default: 'true'
33+
UsePRTitleAsNotesHeading:
34+
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.
35+
required: false
36+
default: 'true'
37+
38+
runs:
39+
using: composite
40+
steps:
41+
- name: Install-PSModule
42+
uses: ./_wf/.github/actions/Install-PSModule
43+
44+
- name: Download module artifact
45+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
46+
with:
47+
name: ${{ inputs.ArtifactName }}
48+
path: ${{ inputs.ModulePath }}
49+
50+
- name: Create GitHub release
51+
shell: pwsh
52+
working-directory: ${{ inputs.WorkingDirectory }}
53+
env:
54+
PSMODULE_RELEASE_PSMODULE_INPUT_Name: ${{ inputs.Name }}
55+
PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath: ${{ inputs.ModulePath }}
56+
PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf: ${{ inputs.WhatIf }}
57+
PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes: ${{ inputs.UsePRBodyAsReleaseNotes }}
58+
PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName: ${{ inputs.UsePRTitleAsReleaseName }}
59+
PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading: ${{ inputs.UsePRTitleAsNotesHeading }}
60+
run: ${{ github.action_path }}/src/release.ps1
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
2+
'PSUseDeclaredVarsMoreThanAssignments', 'usePRBodyAsReleaseNotes',
3+
Justification = 'Variable is used in script blocks.'
4+
)]
5+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
6+
'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsReleaseName',
7+
Justification = 'Variable is used in script blocks.'
8+
)]
9+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
10+
'PSUseDeclaredVarsMoreThanAssignments', 'usePRTitleAsNotesHeading',
11+
Justification = 'Variable is used in script blocks.'
12+
)]
13+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
14+
'PSUseDeclaredVarsMoreThanAssignments', 'prNumber',
15+
Justification = 'Variable is used in script blocks.'
16+
)]
17+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
18+
'PSUseDeclaredVarsMoreThanAssignments', 'prHeadRef',
19+
Justification = 'Variable is used in script blocks.'
20+
)]
21+
[CmdletBinding()]
22+
param()
23+
24+
$PSStyle.OutputRendering = 'Ansi'
25+
26+
Import-Module -Name 'PSModule' -Force
27+
28+
#region Load inputs
29+
LogGroup 'Load inputs' {
30+
$env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/'
31+
32+
$name = if ([string]::IsNullOrEmpty($env:PSMODULE_RELEASE_PSMODULE_INPUT_Name)) {
33+
$env:GITHUB_REPOSITORY_NAME
34+
} else {
35+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_Name
36+
}
37+
# Normalize to an absolute path anchored at the workspace root so that
38+
# the resolved location agrees with where actions/download-artifact writes
39+
# the artifact (workspace-root-relative), regardless of WorkingDirectory.
40+
$modulePathInput = $env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath
41+
$modulePathCandidate = if ([System.IO.Path]::IsPathRooted($modulePathInput)) {
42+
Join-Path -Path $modulePathInput -ChildPath $name
43+
} else {
44+
Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath $modulePathInput -AdditionalChildPath $name
45+
}
46+
if (-not (Test-Path -Path $modulePathCandidate -PathType Container)) {
47+
Write-Error ("Module directory not found at [$modulePathCandidate]. " +
48+
'Ensure the artifact contains a <ModulePath>/<Name>/ subdirectory layout.')
49+
exit 1
50+
}
51+
$modulePath = Resolve-Path -Path $modulePathCandidate | Select-Object -ExpandProperty Path
52+
$whatIf = $env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf -eq 'true'
53+
$usePRBodyAsReleaseNotes = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes -eq 'true'
54+
$usePRTitleAsReleaseName = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName -eq 'true'
55+
$usePRTitleAsNotesHeading = $env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading -eq 'true'
56+
57+
Write-Host "Module name: [$name]"
58+
Write-Host "Module path: [$modulePath]"
59+
Write-Host "WhatIf: [$whatIf]"
60+
}
61+
#endregion Load inputs
62+
63+
#region Load PR information
64+
LogGroup 'Load PR information' {
65+
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
66+
$githubEvent = $githubEventJson | ConvertFrom-Json
67+
$pull_request = $githubEvent.pull_request
68+
if (-not $pull_request) {
69+
throw 'GitHub event does not contain pull_request data. This script must be run from a pull_request event.'
70+
}
71+
$prNumber = $pull_request.number
72+
$prHeadRef = $pull_request.head.ref
73+
}
74+
#endregion Load PR information
75+
76+
#region Resolve version from manifest
77+
LogGroup 'Resolve version from manifest' {
78+
Add-PSModulePath -Path (Split-Path -Path $modulePath -Parent)
79+
$manifestFilePath = Join-Path -Path $modulePath -ChildPath "$name.psd1"
80+
Write-Host "Module manifest file path: [$manifestFilePath]"
81+
if (-not (Test-Path -Path $manifestFilePath)) {
82+
Write-Error "Module manifest file not found at [$manifestFilePath]"
83+
exit 1
84+
}
85+
86+
Show-FileContent -Path $manifestFilePath
87+
88+
$manifest = Test-ModuleManifest -Path $manifestFilePath -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
89+
if ($manifest) {
90+
Write-Host "Manifest validated: [$($manifest.Name)] v[$($manifest.Version)]"
91+
} else {
92+
Write-Host '::warning::Test-ModuleManifest returned warnings (e.g. unresolved RequiredModules). Continuing with data-file validation.'
93+
}
94+
95+
try {
96+
$manifestData = Import-PowerShellDataFile -Path $manifestFilePath
97+
} catch {
98+
Write-Error "Failed to import manifest data file [$manifestFilePath]: $($_.Exception.Message)"
99+
exit 1
100+
}
101+
$moduleVersion = $manifestData.ModuleVersion
102+
if (-not ($moduleVersion -match '^\d+\.\d+\.\d+$')) {
103+
Write-Error ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " +
104+
'Ensure Build-PSModule has stamped the artifact with a final version.')
105+
exit 1
106+
}
107+
if ($moduleVersion -eq '999.0.0') {
108+
Write-Error ('ModuleVersion is the placeholder [999.0.0]. ' +
109+
'The artifact was not stamped with a real version by the build step.')
110+
exit 1
111+
}
112+
$prerelease = $manifestData.PrivateData.PSData.Prerelease
113+
if ([string]::IsNullOrWhiteSpace($prerelease)) {
114+
$prerelease = ''
115+
$createPrerelease = $false
116+
} else {
117+
if ($prerelease -notmatch '^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$') {
118+
Write-Error ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " +
119+
'It must contain only alphanumerics, hyphens, and dots as separators.')
120+
exit 1
121+
}
122+
$createPrerelease = $true
123+
}
124+
125+
$releaseTag = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion }
126+
$releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' }
127+
$publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion }
128+
129+
[PSCustomObject]@{
130+
ModuleVersion = $moduleVersion
131+
Prerelease = $prerelease
132+
CreatePrerelease = $createPrerelease
133+
ReleaseTag = $releaseTag
134+
PRNumber = $prNumber
135+
PRHeadRef = $prHeadRef
136+
} | Format-List | Out-String
137+
138+
# Expose release tag to subsequent steps so cleanup can exclude the just-created tag.
139+
"PSMODULE_PUBLISH_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM
140+
}
141+
#endregion Resolve version from manifest
142+
143+
#region Create GitHub release with module artifact attached
144+
# A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes.
145+
LogGroup 'Create GitHub release' {
146+
$releaseCreateCommand = @('release', 'create', $releaseTag)
147+
$notesFilePath = $null
148+
149+
if ($usePRTitleAsReleaseName -and $pull_request.title) {
150+
$releaseCreateCommand += @('--title', $pull_request.title)
151+
Write-Host "Using PR title as release name: [$($pull_request.title)]"
152+
} else {
153+
$releaseCreateCommand += @('--title', $releaseTag)
154+
}
155+
156+
# Build release notes content. Uses temp file to avoid escaping issues with special characters.
157+
# Precedence rules for the three UsePR* parameters:
158+
# 1. UsePRTitleAsNotesHeading + UsePRBodyAsReleaseNotes: Creates "# Title (#PR)\n\nBody" format.
159+
# 2. UsePRBodyAsReleaseNotes only: Uses PR body as-is.
160+
# 3. Fallback: Auto-generates notes via GitHub's --generate-notes.
161+
if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) {
162+
$notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)"
163+
$notesFilePath = [System.IO.Path]::GetTempFileName()
164+
Set-Content -Path $notesFilePath -Value $notes -Encoding utf8
165+
$releaseCreateCommand += @('--notes-file', $notesFilePath)
166+
Write-Host 'Using PR title as H1 heading with link and body as release notes'
167+
} elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) {
168+
$notesFilePath = [System.IO.Path]::GetTempFileName()
169+
Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8
170+
$releaseCreateCommand += @('--notes-file', $notesFilePath)
171+
Write-Host 'Using PR body as release notes'
172+
} else {
173+
$releaseCreateCommand += @('--generate-notes')
174+
}
175+
176+
if ($createPrerelease) {
177+
$releaseCreateCommand += @('--target', $prHeadRef, '--prerelease')
178+
}
179+
180+
if ($whatIf) {
181+
Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')"
182+
$releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag"
183+
} else {
184+
try {
185+
$releaseURL = gh @releaseCreateCommand
186+
if ($LASTEXITCODE -ne 0) {
187+
Write-Error "Failed to create the release [$releaseTag]."
188+
exit $LASTEXITCODE
189+
}
190+
} finally {
191+
if ($notesFilePath -and (Test-Path -Path $notesFilePath)) {
192+
Remove-Item -Path $notesFilePath -Force
193+
}
194+
}
195+
}
196+
197+
# Attach the built module as a release artifact so consumers can download the exact bytes.
198+
$zipFileName = "$name-$publishPSVersion.zip"
199+
$zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipFileName
200+
if (Test-Path -Path $zipPath) {
201+
Remove-Item -Path $zipPath -Force
202+
}
203+
if ($whatIf) {
204+
Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force"
205+
Write-Host "WhatIf: gh release upload $releaseTag $zipPath --clobber"
206+
} else {
207+
Write-Host "Compressing module to [$zipPath]"
208+
Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force
209+
try {
210+
gh release upload $releaseTag $zipPath --clobber
211+
if ($LASTEXITCODE -ne 0) {
212+
Write-Error "Failed to upload module artifact to release [$releaseTag]."
213+
exit $LASTEXITCODE
214+
}
215+
Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName"
216+
} finally {
217+
if (Test-Path -Path $zipPath) {
218+
Remove-Item -Path $zipPath -Force
219+
}
220+
}
221+
}
222+
223+
if ($whatIf) {
224+
Write-Host "gh pr comment $prNumber -b '✅ $($releaseType): GitHub - $name $releaseTag'"
225+
} else {
226+
gh pr comment $prNumber -b "$releaseType`: GitHub - [$name $releaseTag]($releaseURL)"
227+
if ($LASTEXITCODE -ne 0) {
228+
Write-Error 'Failed to comment on the pull request.'
229+
exit $LASTEXITCODE
230+
}
231+
}
232+
Write-Host "::notice title=✅ $releaseType`: GitHub - $name $releaseTag::$releaseURL"
233+
}
234+
#endregion Create GitHub release
235+
236+
Write-Host "Release creation complete. Version: [$releaseTag]"

.github/workflows/Publish-Module.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,25 @@ jobs:
4747
ModulePath: outputs/module
4848
APIKey: ${{ secrets.APIKey }}
4949
WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }}
50+
WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }}
51+
52+
- name: Create GitHub release
53+
if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'None'
54+
uses: ./_wf/.github/actions/Release-PSModule
55+
env:
56+
GH_TOKEN: ${{ github.token }}
57+
with:
58+
Name: ${{ fromJson(inputs.Settings).Name }}
59+
ModulePath: outputs/module
60+
ArtifactName: module
61+
WhatIf: ${{ github.repository == 'PSModule/Process-PSModule' }}
5062
UsePRTitleAsReleaseName: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsReleaseName }}
5163
UsePRBodyAsReleaseNotes: ${{ fromJson(inputs.Settings).Publish.Module.UsePRBodyAsReleaseNotes }}
5264
UsePRTitleAsNotesHeading: ${{ fromJson(inputs.Settings).Publish.Module.UsePRTitleAsNotesHeading }}
5365
WorkingDirectory: ${{ fromJson(inputs.Settings).WorkingDirectory }}
5466

5567
- name: Cleanup prereleases
56-
if: fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease'
68+
if: always() && !cancelled() && fromJson(inputs.Settings).Publish.Module.Resolution.ReleaseType != 'Prerelease'
5769
uses: ./_wf/.github/actions/Cleanup-PSModulePrereleases
5870
env:
5971
GH_TOKEN: ${{ github.token }}

0 commit comments

Comments
 (0)