Skip to content

Commit 034d5aa

Browse files
Make GitHub release retries resumable
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent e1a2ac5 commit 034d5aa

5 files changed

Lines changed: 165 additions & 95 deletions

File tree

.github/actions/Cleanup-PSModulePrereleases/action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ inputs:
77
description: Control whether to automatically delete prerelease tags.
88
required: false
99
default: 'true'
10+
ReleaseTag:
11+
description: GitHub release tag to retain during cleanup.
12+
required: false
13+
default: ''
1014
WhatIf:
1115
description: If specified, the action will only log the changes it would make.
1216
required: false
@@ -28,5 +32,5 @@ runs:
2832
working-directory: ${{ inputs.WorkingDirectory }}
2933
env:
3034
PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf: ${{ inputs.WhatIf }}
31-
PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ env.PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag }}
35+
PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag: ${{ inputs.ReleaseTag }}
3236
run: ${{ github.action_path }}/src/cleanup.ps1

.github/actions/Release-PSModule/action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ inputs:
3838
description: Full GitHub release tag resolved by Resolve-PSModuleVersion.
3939
required: true
4040

41+
outputs:
42+
ReleaseTag:
43+
description: Full GitHub release tag created or resumed by this action.
44+
value: ${{ steps.release.outputs.ReleaseTag }}
45+
ReleaseUrl:
46+
description: URL of the GitHub release created or resumed by this action.
47+
value: ${{ steps.release.outputs.ReleaseUrl }}
48+
4149
runs:
4250
using: composite
4351
steps:
@@ -51,6 +59,7 @@ runs:
5159
path: ${{ inputs.ModulePath }}
5260

5361
- name: Create GitHub release
62+
id: release
5463
shell: pwsh
5564
working-directory: ${{ inputs.WorkingDirectory }}
5665
env:

.github/actions/Release-PSModule/src/release.ps1

Lines changed: 93 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,26 @@
1-
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
1+
#Requires -Version 7.0
2+
#Requires -Modules PSModule
3+
4+
<#
5+
.SYNOPSIS
6+
Creates or resumes a GitHub release for a versioned PowerShell module artifact.
7+
8+
.DESCRIPTION
9+
Validates the supplied module artifact, creates a GitHub release when its tag does not
10+
already exist, uploads the artifact ZIP, and reports the result on the source pull request.
11+
12+
.EXAMPLE
13+
./release.ps1
14+
15+
Creates or resumes the release using inputs supplied by the Release-PSModule action.
16+
17+
.INPUTS
18+
None. The Release-PSModule composite action supplies values through environment variables.
19+
20+
.OUTPUTS
21+
None. The script writes ReleaseTag and ReleaseUrl to the GitHub Actions output file.
22+
#>
23+
[Diagnostics.CodeAnalysis.SuppressMessageAttribute(
224
'PSUseDeclaredVarsMoreThanAssignments', 'usePRBodyAsReleaseNotes',
325
Justification = 'Variable is used in script blocks.'
426
)]
@@ -25,11 +47,11 @@
2547
[CmdletBinding()]
2648
param()
2749

50+
$ErrorActionPreference = 'Stop'
2851
$PSStyle.OutputRendering = 'Ansi'
2952

3053
Import-Module -Name 'PSModule' -Force
3154

32-
#region Load inputs
3355
LogGroup 'Load inputs' {
3456
$env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/'
3557

@@ -67,9 +89,7 @@ LogGroup 'Load inputs' {
6789
Write-Host "Release tag: [$releaseTag]"
6890
Write-Host "WhatIf: [$whatIf]"
6991
}
70-
#endregion Load inputs
7192

72-
#region Load PR information
7393
LogGroup 'Load PR information' {
7494
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
7595
$githubEvent = $githubEventJson | ConvertFrom-Json
@@ -80,9 +100,7 @@ LogGroup 'Load PR information' {
80100
$prNumber = $pull_request.number
81101
$prHeadRef = $pull_request.head.ref
82102
}
83-
#endregion Load PR information
84103

85-
#region Resolve version from manifest
86104
LogGroup 'Resolve version from manifest' {
87105
Add-PSModulePath -Path (Split-Path -Path $modulePath -Parent)
88106
$manifestFilePath = Join-Path -Path $modulePath -ChildPath "$name.psd1"
@@ -101,100 +119,101 @@ LogGroup 'Resolve version from manifest' {
101119
Write-Host '::warning::Test-ModuleManifest returned warnings (e.g. unresolved RequiredModules). Continuing with data-file validation.'
102120
}
103121

104-
try {
105-
$manifestData = Import-PowerShellDataFile -Path $manifestFilePath
106-
} catch {
107-
Write-Error "Failed to import manifest data file [$manifestFilePath]: $($_.Exception.Message)"
108-
exit 1
109-
}
122+
$manifestData = Import-PowerShellDataFile -Path $manifestFilePath -ErrorAction Stop
110123
$moduleVersion = $manifestData.ModuleVersion
111124
if (-not ($moduleVersion -match '^\d+\.\d+\.\d+$')) {
112-
Write-Error ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " +
125+
throw ("ModuleVersion [$moduleVersion] must be in Major.Minor.Patch format. " +
113126
'Ensure Build-PSModule has stamped the artifact with a final version.')
114-
exit 1
115127
}
116128
if ($moduleVersion -eq '999.0.0') {
117-
Write-Error ('ModuleVersion is the placeholder [999.0.0]. ' +
129+
throw ('ModuleVersion is the placeholder [999.0.0]. ' +
118130
'The artifact was not stamped with a real version by the build step.')
119-
exit 1
120131
}
121132
$prerelease = $manifestData.PrivateData.PSData.Prerelease
122133
if ([string]::IsNullOrWhiteSpace($prerelease)) {
123134
$prerelease = ''
124135
$createPrerelease = $false
125136
} else {
126137
if ($prerelease -notmatch '^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$') {
127-
Write-Error ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " +
138+
throw ("Prerelease label [$prerelease] is not a valid SemVer prerelease identifier. " +
128139
'It must contain only alphanumerics, hyphens, and dots as separators.')
129-
exit 1
130140
}
131141
$createPrerelease = $true
132142
}
133143

134144
$releaseType = if ($createPrerelease) { 'New prerelease' } else { 'New release' }
135145
$publishPSVersion = if ($createPrerelease) { "$moduleVersion-$prerelease" } else { $moduleVersion }
136146

137-
[PSCustomObject]@{
138-
ModuleVersion = $moduleVersion
139-
Prerelease = $prerelease
140-
CreatePrerelease = $createPrerelease
141-
GalleryVersion = $publishPSVersion
142-
ReleaseTag = $releaseTag
143-
PRNumber = $prNumber
144-
PRHeadRef = $prHeadRef
145-
} | Format-List | Out-String
146-
147-
# Expose release tag to subsequent steps so cleanup can exclude the just-created tag.
148-
"PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_ENV -Append -Encoding utf8NoBOM
147+
Write-Host (
148+
[PSCustomObject]@{
149+
ModuleVersion = $moduleVersion
150+
Prerelease = $prerelease
151+
CreatePrerelease = $createPrerelease
152+
GalleryVersion = $publishPSVersion
153+
ReleaseTag = $releaseTag
154+
PRNumber = $prNumber
155+
PRHeadRef = $prHeadRef
156+
} | Format-List | Out-String
157+
)
149158
}
150-
#endregion Resolve version from manifest
151159

152-
#region Create GitHub release with module artifact attached
153160
# A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes.
154161
LogGroup 'Create GitHub release' {
155162
$releaseCreateCommand = @('release', 'create', $releaseTag)
156163
$notesFilePath = $null
164+
$releaseExists = $false
157165

158-
if ($usePRTitleAsReleaseName -and $pull_request.title) {
159-
$releaseCreateCommand += @('--title', $pull_request.title)
160-
Write-Host "Using PR title as release name: [$($pull_request.title)]"
161-
} else {
162-
$releaseCreateCommand += @('--title', $releaseTag)
163-
}
164-
165-
# Build release notes content. Uses temp file to avoid escaping issues with special characters.
166-
# Precedence rules for the three UsePR* parameters:
167-
# 1. UsePRTitleAsNotesHeading + UsePRBodyAsReleaseNotes: Creates "# Title (#PR)\n\nBody" format.
168-
# 2. UsePRBodyAsReleaseNotes only: Uses PR body as-is.
169-
# 3. Fallback: Auto-generates notes via GitHub's --generate-notes.
170-
if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) {
171-
$notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)"
172-
$notesFilePath = [System.IO.Path]::GetTempFileName()
173-
Set-Content -Path $notesFilePath -Value $notes -Encoding utf8
174-
$releaseCreateCommand += @('--notes-file', $notesFilePath)
175-
Write-Host 'Using PR title as H1 heading with link and body as release notes'
176-
} elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) {
177-
$notesFilePath = [System.IO.Path]::GetTempFileName()
178-
Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8
179-
$releaseCreateCommand += @('--notes-file', $notesFilePath)
180-
Write-Host 'Using PR body as release notes'
181-
} else {
182-
$releaseCreateCommand += @('--generate-notes')
183-
}
166+
if (-not $whatIf) {
167+
$encodedReleaseTag = [System.Uri]::EscapeDataString($releaseTag)
168+
$releaseLookupResult = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$encodedReleaseTag" --jq '.html_url' 2>&1
169+
$releaseLookupExitCode = $LASTEXITCODE
184170

185-
if ($createPrerelease) {
186-
$releaseCreateCommand += @('--target', $prHeadRef, '--prerelease')
171+
if ($releaseLookupExitCode -eq 0) {
172+
$releaseURL = ($releaseLookupResult | Out-String).Trim()
173+
$releaseExists = $true
174+
Write-Host "GitHub release [$releaseTag] already exists. Resuming its artifact upload."
175+
} elseif (($releaseLookupResult | Out-String) -notmatch 'HTTP 404') {
176+
throw "Unable to determine whether release [$releaseTag] exists: $($releaseLookupResult | Out-String)"
177+
}
187178
}
188179

189-
if ($whatIf) {
190-
Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')"
191-
$releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag"
192-
} else {
180+
if (-not $releaseExists) {
181+
if ($usePRTitleAsReleaseName -and $pull_request.title) {
182+
$releaseCreateCommand += @('--title', $pull_request.title)
183+
Write-Host "Using PR title as release name: [$($pull_request.title)]"
184+
} else {
185+
$releaseCreateCommand += @('--title', $releaseTag)
186+
}
187+
188+
# Build release notes content. Uses a file to preserve special characters.
189+
if ($usePRTitleAsNotesHeading -and $usePRBodyAsReleaseNotes -and $pull_request.title -and $pull_request.body) {
190+
$notes = "# $($pull_request.title) (#$prNumber)`n`n$($pull_request.body)"
191+
$notesFilePath = [System.IO.Path]::GetTempFileName()
192+
Set-Content -Path $notesFilePath -Value $notes -Encoding utf8
193+
$releaseCreateCommand += @('--notes-file', $notesFilePath)
194+
Write-Host 'Using PR title as H1 heading with link and body as release notes'
195+
} elseif ($usePRBodyAsReleaseNotes -and $pull_request.body) {
196+
$notesFilePath = [System.IO.Path]::GetTempFileName()
197+
Set-Content -Path $notesFilePath -Value $pull_request.body -Encoding utf8
198+
$releaseCreateCommand += @('--notes-file', $notesFilePath)
199+
Write-Host 'Using PR body as release notes'
200+
} else {
201+
$releaseCreateCommand += @('--generate-notes')
202+
}
203+
204+
if ($createPrerelease) {
205+
$releaseCreateCommand += @('--target', $prHeadRef, '--prerelease')
206+
}
207+
193208
try {
194-
$releaseURL = gh @releaseCreateCommand
195-
if ($LASTEXITCODE -ne 0) {
196-
Write-Error "Failed to create the release [$releaseTag]."
197-
exit $LASTEXITCODE
209+
if ($whatIf) {
210+
Write-Host "WhatIf: gh $($releaseCreateCommand -join ' ')"
211+
$releaseURL = "https://github.com/$env:GITHUB_REPOSITORY/releases/tag/$releaseTag"
212+
} else {
213+
$releaseURL = gh @releaseCreateCommand
214+
if ($LASTEXITCODE -ne 0) {
215+
throw "Failed to create the release [$releaseTag]."
216+
}
198217
}
199218
} finally {
200219
if ($notesFilePath -and (Test-Path -Path $notesFilePath)) {
@@ -218,8 +237,7 @@ LogGroup 'Create GitHub release' {
218237
try {
219238
gh release upload $releaseTag $zipPath --clobber
220239
if ($LASTEXITCODE -ne 0) {
221-
Write-Error "Failed to upload module artifact to release [$releaseTag]."
222-
exit $LASTEXITCODE
240+
throw "Failed to upload module artifact to release [$releaseTag]."
223241
}
224242
Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName"
225243
} finally {
@@ -234,12 +252,14 @@ LogGroup 'Create GitHub release' {
234252
} else {
235253
gh pr comment $prNumber -b "$releaseType`: GitHub - [$name $releaseTag]($releaseURL)"
236254
if ($LASTEXITCODE -ne 0) {
237-
Write-Error 'Failed to comment on the pull request.'
238-
exit $LASTEXITCODE
255+
throw 'Failed to comment on the pull request.'
239256
}
240257
}
258+
if ($env:GITHUB_OUTPUT) {
259+
"ReleaseTag=$releaseTag" | Out-File -Path $env:GITHUB_OUTPUT -Append -Encoding utf8NoBOM
260+
"ReleaseUrl=$releaseURL" | Out-File -Path $env:GITHUB_OUTPUT -Append -Encoding utf8NoBOM
261+
}
241262
Write-Host "::notice title=✅ $releaseType`: GitHub - $name $releaseTag::$releaseURL"
242263
}
243-
#endregion Create GitHub release
244264

245265
Write-Host "Release creation complete. Version: [$releaseTag]"

.github/actions/Release-PSModule/tests/Release-PSModule.WhatIf.Tests.ps1

Lines changed: 50 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ BeforeAll {
1010

1111
$script:releaseScriptPath = Join-Path -Path $PSScriptRoot -ChildPath '../src/release.ps1'
1212
$script:environmentVariableNames = @(
13-
'GITHUB_ENV'
1413
'GITHUB_EVENT_PATH'
14+
'GITHUB_OUTPUT'
1515
'GITHUB_REPOSITORY'
1616
'GITHUB_WORKSPACE'
17+
'RELEASE_ACTION_GH_CALL_LOG'
1718
'PSMODULE_RELEASE_PSMODULE_INPUT_Name'
1819
'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath'
1920
'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf'
@@ -32,22 +33,26 @@ AfterAll {
3233
foreach ($name in $script:environmentVariableNames) {
3334
[System.Environment]::SetEnvironmentVariable($name, $script:originalEnvironment[$name])
3435
}
36+
Remove-Item -Path function:global:gh -ErrorAction SilentlyContinue
3537
}
3638

3739
Describe 'Release-PSModule WhatIf' {
38-
It 'creates a prefixed prerelease tag without GitHub side effects' {
39-
$moduleName = 'TestModule'
40-
$workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace'
41-
$modulePath = Join-Path -Path $workspacePath -ChildPath "outputs/module/$moduleName"
42-
$manifestPath = Join-Path -Path $modulePath -ChildPath "$moduleName.psd1"
40+
BeforeEach {
41+
$script:moduleName = 'TestModule'
42+
$script:workspacePath = Join-Path -Path $TestDrive -ChildPath 'workspace'
43+
$script:modulePath = Join-Path -Path $script:workspacePath -ChildPath "outputs/module/$script:moduleName"
44+
$manifestPath = Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psd1"
4345
$eventPath = Join-Path -Path $TestDrive -ChildPath 'event.json'
44-
$githubEnvironmentPath = Join-Path -Path $TestDrive -ChildPath 'github-environment'
46+
$script:githubOutputPath = Join-Path -Path $TestDrive -ChildPath 'github-output'
47+
$script:ghCallLogPath = Join-Path -Path $TestDrive -ChildPath 'gh-calls'
4548

46-
New-Item -Path $modulePath -ItemType Directory -Force | Out-Null
47-
Set-Content -Path (Join-Path -Path $modulePath -ChildPath "$moduleName.psm1") -Value ''
49+
$null = New-Item -Path $script:modulePath -ItemType Directory -Force
50+
$null = New-Item -Path $script:githubOutputPath -ItemType File -Force
51+
$null = New-Item -Path $script:ghCallLogPath -ItemType File -Force
52+
Set-Content -Path (Join-Path -Path $script:modulePath -ChildPath "$script:moduleName.psm1") -Value ''
4853
Set-Content -Path $manifestPath -Value @"
4954
@{
50-
RootModule = '$moduleName.psm1'
55+
RootModule = '$script:moduleName.psm1'
5156
ModuleVersion = '1.2.3'
5257
GUID = '3c0f8d73-60b7-4f38-b3cf-9386db4982a4'
5358
Author = 'PSModule'
@@ -71,20 +76,50 @@ Describe 'Release-PSModule WhatIf' {
7176
}
7277
} | ConvertTo-Json -Depth 5 | Set-Content -Path $eventPath
7378

74-
$env:GITHUB_ENV = $githubEnvironmentPath
7579
$env:GITHUB_EVENT_PATH = $eventPath
76-
$env:GITHUB_REPOSITORY = "PSModule/$moduleName"
77-
$env:GITHUB_WORKSPACE = $workspacePath
78-
$env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $moduleName
80+
$env:GITHUB_OUTPUT = $script:githubOutputPath
81+
$env:GITHUB_REPOSITORY = "PSModule/$script:moduleName"
82+
$env:GITHUB_WORKSPACE = $script:workspacePath
83+
$env:RELEASE_ACTION_GH_CALL_LOG = $script:ghCallLogPath
84+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = $script:moduleName
7985
$env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = 'outputs/module'
8086
$env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'true'
8187
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'true'
8288
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false'
8389
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'true'
8490
$env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.3-preview.1'
91+
}
92+
93+
It 'creates a prefixed prerelease tag without GitHub side effects' {
94+
{ & $script:releaseScriptPath } | Should -Not -Throw
95+
96+
Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1'
97+
Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseUrl=https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1'
98+
}
99+
100+
It 'resumes an existing release after a partial failure' {
101+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false'
102+
Set-Item -Path function:global:gh -Value {
103+
[CmdletBinding()]
104+
param(
105+
[Parameter(ValueFromRemainingArguments)]
106+
[string[]] $Arguments
107+
)
108+
109+
Add-Content -Path $env:RELEASE_ACTION_GH_CALL_LOG -Value ($Arguments -join ' ')
110+
$global:LASTEXITCODE = 0
111+
if ($Arguments[0] -eq 'api') {
112+
return 'https://github.com/PSModule/TestModule/releases/tag/v1.2.3-preview.1'
113+
}
114+
}
85115

86116
{ & $script:releaseScriptPath } | Should -Not -Throw
87117

88-
Get-Content -Path $githubEnvironmentPath | Should -Contain 'PSMODULE_RELEASE_PSMODULE_CONTEXT_ReleaseTag=v1.2.3-preview.1'
118+
$ghCalls = Get-Content -Path $script:ghCallLogPath -Raw
119+
$ghCalls | Should -Match 'api repos/PSModule/TestModule/releases/tags/v1.2.3-preview.1 --jq .html_url'
120+
$ghCalls | Should -Match 'release upload v1.2.3-preview.1'
121+
$ghCalls | Should -Match 'pr comment 42'
122+
$ghCalls | Should -Not -Match 'release create'
123+
Get-Content -Path $script:githubOutputPath | Should -Contain 'ReleaseTag=v1.2.3-preview.1'
89124
}
90125
}

0 commit comments

Comments
 (0)