Skip to content

Commit c4decfa

Browse files
Verify release tag ownership and retain immutable published assets
1 parent 38ff9ff commit c4decfa

5 files changed

Lines changed: 305 additions & 43 deletions

File tree

‎.github/actions/Release-PSModule/README.md‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ Creates or resumes a GitHub release for a pre-versioned PowerShell module artifa
1010
| `ModulePath` | Path containing the built `<Name>/` module directory. | No | `.PSModule/module` |
1111
| `ArtifactName` | Name of the module artifact to download. | No | `module` |
1212
| `ReleaseTag` | Full GitHub release tag resolved by `Resolve-PSModuleVersion`. | Yes | N/A |
13+
| `CommitSha` | Original immutable source commit; used to verify ownership on a retry. | No | Original `GITHUB_SHA` context for retry verification |
14+
| `PullRequest` | Normalized source PR context; direct releases also remain supported. | No | Event-payload fallback |
1315
| `WhatIf` | Logs release operations without creating or uploading anything. | No | `false` |
1416
| `WorkingDirectory` | Directory where the release script runs. | No | `.` |
1517
| `UsePRTitleAsReleaseName` | Uses the pull request title as the release name. | No | `false` |
@@ -36,6 +38,19 @@ Pass the Plan job's resolved `FullVersion` as `ReleaseTag` so the configured tag
3638
ModulePath: .PSModule/module
3739
ArtifactName: module
3840
ReleaseTag: ${{ fromJson(inputs.Settings).Publish.Module.Resolution.FullVersion }}
41+
CommitSha: ${{ fromJson(inputs.Settings).Context.CommitSha }}
3942
```
4043
41-
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.
44+
The action consumes a built module artifact and normalized PR or direct-release context. An existing tag is resolved
45+
to its actual commit, including annotated tags; `target_commitish` is not treated as immutable proof. A retry proceeds
46+
only when that commit matches the original source.
47+
48+
## Retrying publication
49+
50+
Use the original run's resolved tag, source, and artifact. A missing ZIP can be uploaded to the same-source release.
51+
An existing ZIP is retained only when its published SHA-256 digest matches the local bytes. Different or unverifiable
52+
published contents are rejected explicitly; the action never uses `--clobber`.
53+
54+
If a tag belongs to another commit, or an artifact differs, stop and reconcile the conflict through reviewed
55+
forward-only work. Do not retag, replace an existing version's asset, or retry a newly computed bump as if it were the
56+
original publication. `WhatIf` reports commands without creating, replacing, or deleting release state.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
$ErrorActionPreference = 'Stop'
2+
3+
function Resolve-PSModuleReleaseTagCommit {
4+
<#
5+
.SYNOPSIS
6+
Resolve a release tag to its exact immutable commit.
7+
8+
.DESCRIPTION
9+
Peels annotated tags and rejects unresolved or cyclic references. The
10+
release's mutable target_commitish hint is not used as ownership proof.
11+
12+
.EXAMPLE
13+
Resolve-PSModuleReleaseTagCommit -Tag $tag -GetTagReference $readRef -GetTagObject $readTag
14+
15+
Returns the commit referenced by the release tag.
16+
17+
.INPUTS
18+
None
19+
20+
You can't pipe objects to Resolve-PSModuleReleaseTagCommit.
21+
22+
.OUTPUTS
23+
System.String
24+
25+
The immutable commit SHA referenced by the tag.
26+
#>
27+
[CmdletBinding()]
28+
[OutputType([string])]
29+
param(
30+
# The exact release tag name.
31+
[Parameter(Mandatory)]
32+
[ValidateNotNullOrEmpty()]
33+
[string] $Tag,
34+
35+
# Returns the object referenced by the tag name.
36+
[Parameter(Mandatory)]
37+
[scriptblock] $GetTagReference,
38+
39+
# Returns the object targeted by an annotated-tag SHA.
40+
[Parameter(Mandatory)]
41+
[scriptblock] $GetTagObject
42+
)
43+
44+
$reference = & $GetTagReference $Tag
45+
$visited = [System.Collections.Generic.HashSet[string]]::new()
46+
while ($reference.type -eq 'tag') {
47+
if ([string]::IsNullOrWhiteSpace($reference.sha) -or -not $visited.Add($reference.sha)) {
48+
throw "Cannot determine release source: tag [$Tag] has invalid or cyclic tag data."
49+
}
50+
$reference = & $GetTagObject $reference.sha
51+
}
52+
if ($reference.type -ne 'commit' -or [string]::IsNullOrWhiteSpace($reference.sha)) {
53+
throw "Cannot determine the immutable commit for release tag [$Tag]."
54+
}
55+
[string]$reference.sha
56+
}

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

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@ $ErrorActionPreference = 'Stop'
5151
$PSStyle.OutputRendering = 'Ansi'
5252

5353
Import-Module -Name 'PSModule' -Force
54+
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Release-PSModule.Helpers.psm1') -Force
5455

55-
LogGroup 'Load inputs' {
56+
Set-GitHubLogGroup 'Load inputs' {
5657
$env:GITHUB_REPOSITORY_NAME = $env:GITHUB_REPOSITORY -replace '.+/'
5758

5859
$name = if ([string]::IsNullOrEmpty($env:PSMODULE_RELEASE_PSMODULE_INPUT_Name)) {
@@ -91,7 +92,7 @@ LogGroup 'Load inputs' {
9192
Write-Host "WhatIf: [$whatIf]"
9293
}
9394

94-
LogGroup 'Load release context' {
95+
Set-GitHubLogGroup 'Load release context' {
9596
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
9697
$githubEvent = $githubEventJson | ConvertFrom-Json
9798
$pullRequestJson = $env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest
@@ -122,7 +123,7 @@ LogGroup 'Load release context' {
122123
}
123124
}
124125

125-
LogGroup 'Resolve version from manifest' {
126+
Set-GitHubLogGroup 'Resolve version from manifest' {
126127
Add-PSModulePath -Path (Split-Path -Path $modulePath -Parent)
127128
$manifestFilePath = Join-Path -Path $modulePath -ChildPath "$name.psd1"
128129
Write-Host "Module manifest file path: [$manifestFilePath]"
@@ -180,20 +181,55 @@ LogGroup 'Resolve version from manifest' {
180181
}
181182

182183
# A zip of the built module is uploaded so the GitHub Release page exposes the exact bytes.
183-
LogGroup 'Create GitHub release' {
184+
Set-GitHubLogGroup 'Create GitHub release' {
184185
$releaseCreateCommand = @('release', 'create', $releaseTag)
185186
$notesFilePath = $null
186187
$releaseExists = $false
188+
$existingRelease = $null
187189

188190
if (-not $whatIf) {
189191
$encodedReleaseTag = [System.Uri]::EscapeDataString($releaseTag)
190-
$releaseLookupResult = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$encodedReleaseTag" --jq '.html_url' 2>&1
192+
$releaseLookupResult = gh api "repos/$env:GITHUB_REPOSITORY/releases/tags/$encodedReleaseTag" 2>&1
191193
$releaseLookupExitCode = $LASTEXITCODE
192194

193195
if ($releaseLookupExitCode -eq 0) {
194-
$releaseURL = ($releaseLookupResult | Out-String).Trim()
196+
$existingRelease = ($releaseLookupResult | Out-String) | ConvertFrom-Json
197+
if ($existingRelease.tag_name -cne $releaseTag -or
198+
[string]::IsNullOrWhiteSpace($existingRelease.html_url) -or
199+
$existingRelease.PSObject.Properties.Name -notcontains 'assets') {
200+
throw "Cannot verify the existing release metadata for [$releaseTag]."
201+
}
202+
$releaseURL = $existingRelease.html_url
195203
$releaseExists = $true
196-
Write-Host "GitHub release [$releaseTag] already exists. Resuming its artifact upload."
204+
$sourceCommit = if (-not [string]::IsNullOrWhiteSpace($commitSha)) { $commitSha } else { $env:GITHUB_SHA }
205+
if ([string]::IsNullOrWhiteSpace($sourceCommit)) {
206+
throw 'CommitSha or the original GITHUB_SHA is required to establish immutable ownership before resuming a release.'
207+
}
208+
$tagParameters = @{
209+
Tag = $releaseTag
210+
GetTagReference = {
211+
param($Tag)
212+
$encodedTag = [Uri]::EscapeDataString($Tag)
213+
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$encodedTag"
214+
if ($LASTEXITCODE -ne 0) {
215+
throw "Cannot read release tag [$Tag] (gh exited $LASTEXITCODE)."
216+
}
217+
($response | ConvertFrom-Json).object
218+
}
219+
GetTagObject = {
220+
param($TagSha)
221+
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/tags/$TagSha"
222+
if ($LASTEXITCODE -ne 0) {
223+
throw "Cannot resolve annotated tag [$TagSha] (gh exited $LASTEXITCODE)."
224+
}
225+
($response | ConvertFrom-Json).object
226+
}
227+
}
228+
$tagCommit = Resolve-PSModuleReleaseTagCommit @tagParameters
229+
if ($tagCommit -cne $sourceCommit) {
230+
throw "Release tag [$releaseTag] belongs to [$tagCommit], not the original source [$sourceCommit]. Refusing to resume."
231+
}
232+
Write-Host "GitHub release [$releaseTag] belongs to [$sourceCommit]. Checking its artifact before resuming."
197233
} elseif (($releaseLookupResult | Out-String) -notmatch 'HTTP 404') {
198234
throw "Unable to determine whether release [$releaseTag] exists: $($releaseLookupResult | Out-String)"
199235
}
@@ -253,26 +289,44 @@ LogGroup 'Create GitHub release' {
253289

254290
# Attach the built module as a release artifact so consumers can download the exact bytes.
255291
$zipFileName = "$name-$publishPSVersion.zip"
256-
$zipPath = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $zipFileName
257-
if (Test-Path -Path $zipPath) {
258-
Remove-Item -Path $zipPath -Force
259-
}
292+
$zipDirectory = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "PSModuleRelease-$([Guid]::NewGuid())"
293+
$zipPath = Join-Path -Path $zipDirectory -ChildPath $zipFileName
260294
if ($whatIf) {
261-
Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force"
262-
Write-Host "WhatIf: gh release upload $releaseTag $zipPath --clobber"
295+
Write-Host "WhatIf: Compress-Archive -Path $modulePath -DestinationPath $zipPath"
296+
Write-Host "WhatIf: gh release upload $releaseTag $zipPath"
263297
} else {
264-
Write-Host "Compressing module to [$zipPath]"
265-
Compress-Archive -Path $modulePath -DestinationPath $zipPath -Force
298+
$null = New-Item -Path $zipDirectory -ItemType Directory
266299
try {
267-
gh release upload $releaseTag $zipPath --clobber
268-
if ($LASTEXITCODE -ne 0) {
269-
throw "Failed to upload module artifact to release [$releaseTag]."
300+
Write-Host "Compressing module to [$zipPath]"
301+
Compress-Archive -Path $modulePath -DestinationPath $zipPath
302+
$matchingAssets = @($existingRelease.assets | Where-Object { $_.name -ceq $zipFileName })
303+
if ($matchingAssets.Count -gt 1) {
304+
throw "Release [$releaseTag] contains ambiguous assets named [$zipFileName]."
305+
}
306+
if ($matchingAssets.Count -eq 1) {
307+
$digest = [regex]::Match([string]$matchingAssets[0].digest, '^sha256:([0-9A-Fa-f]{64})$')
308+
if (-not $digest.Success) {
309+
throw "Cannot verify the SHA-256 digest of the already-published asset [$zipFileName]. Refusing to replace it."
310+
}
311+
$localHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash
312+
if ($localHash -ne $digest.Groups[1].Value) {
313+
throw "Asset [$zipFileName] already has different published contents. Refusing to overwrite it."
314+
}
315+
Write-Host "Existing asset [$zipFileName] has identical contents; retaining its immutable bytes."
316+
} else {
317+
gh release upload $releaseTag $zipPath
318+
if ($LASTEXITCODE -ne 0) {
319+
throw "Failed to upload module artifact to release [$releaseTag]."
320+
}
321+
Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName"
270322
}
271-
Write-Host "::notice title=📦 Attached module artifact to release::$zipFileName"
272323
} finally {
273324
if (Test-Path -Path $zipPath) {
274325
Remove-Item -Path $zipPath -Force
275326
}
327+
if (Test-Path -Path $zipDirectory) {
328+
Remove-Item -Path $zipDirectory -Force
329+
}
276330
}
277331
}
278332

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
BeforeAll {
2+
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath '../src/Release-PSModule.Helpers.psm1') -Force
3+
}
4+
5+
Describe 'Resolve-PSModuleReleaseTagCommit' {
6+
It 'returns the commit from a lightweight release tag' {
7+
$parameters = @{
8+
Tag = 'v1.2.3'
9+
GetTagReference = { [pscustomobject]@{ type = 'commit'; sha = 'original-sha' } }
10+
GetTagObject = { throw 'A lightweight tag must not request an annotated object.' }
11+
}
12+
13+
Resolve-PSModuleReleaseTagCommit @parameters | Should -BeExactly 'original-sha'
14+
}
15+
16+
It 'peels an annotated tag instead of trusting a mutable release target hint' {
17+
$parameters = @{
18+
Tag = 'v1.2.3'
19+
GetTagReference = { [pscustomobject]@{ type = 'tag'; sha = 'tag-object' } }
20+
GetTagObject = {
21+
param($Sha)
22+
$Sha | Should -BeExactly 'tag-object'
23+
[pscustomobject]@{ type = 'commit'; sha = 'original-sha' }
24+
}
25+
}
26+
27+
Resolve-PSModuleReleaseTagCommit @parameters | Should -BeExactly 'original-sha'
28+
}
29+
30+
It 'fails on a cyclic annotated-tag response' {
31+
$parameters = @{
32+
Tag = 'v1.2.3'
33+
GetTagReference = { [pscustomobject]@{ type = 'tag'; sha = 'tag-object' } }
34+
GetTagObject = { [pscustomobject]@{ type = 'tag'; sha = 'tag-object' } }
35+
}
36+
37+
{ Resolve-PSModuleReleaseTagCommit @parameters } | Should -Throw '*cyclic*'
38+
}
39+
40+
It 'fails when a tag does not identify an immutable commit' {
41+
$parameters = @{
42+
Tag = 'v1.2.3'
43+
GetTagReference = { [pscustomobject]@{ type = 'tree'; sha = 'not-a-commit' } }
44+
GetTagObject = { throw 'No annotated lookup is expected.' }
45+
}
46+
47+
{ Resolve-PSModuleReleaseTagCommit @parameters } | Should -Throw '*immutable commit*'
48+
}
49+
}

0 commit comments

Comments
 (0)