Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4fb660b
Enforce namespaced module release decisions
MariusStorhaug Aug 28, 2026
042e7b5
Require explicit non-PR release decisions
MariusStorhaug Aug 28, 2026
6586030
Revert "Require explicit non-PR release decisions"
MariusStorhaug Aug 28, 2026
d5b7c72
Revert "Enforce namespaced module release decisions"
MariusStorhaug Aug 28, 2026
06174d2
Default module releases to namespaced labels
MariusStorhaug Aug 28, 2026
5f1bf27
Document namespaced release defaults
MariusStorhaug Aug 28, 2026
00f66e7
Keep caller guidance on the released workflow
MariusStorhaug Aug 28, 2026
25277e5
Replace automatic patching with a default bump
MariusStorhaug Aug 29, 2026
27dedd6
fix: honour the merged PR version label on a default-branch dispatch
Sep 2, 2026
189d3eb
refactor: make release pull request resolution unit-testable
Sep 2, 2026
8add11c
Use the canonical prerelease qualifier
MariusStorhaug Sep 4, 2026
2646335
Guard dispatch association through composed version tests
MariusStorhaug Oct 31, 2026
ddd57bd
Reconcile dispatch recovery with the current framework base
MariusStorhaug Oct 31, 2026
c2ee566
Compose configurable defaults with dispatch recovery and current fram…
MariusStorhaug Oct 31, 2026
86c4f1b
Restrict prerelease cleanup to the exact published branch target
MariusStorhaug Oct 31, 2026
38ff9ff
Check release-bearing changes before association and document safe re…
MariusStorhaug Oct 31, 2026
da60cf9
Reconcile configurable defaults with reviewed dispatch and cleanup be…
MariusStorhaug Oct 31, 2026
ebf5be5
Validate effective default bumps and release intent across preview an…
MariusStorhaug Oct 31, 2026
c4decfa
Verify release tag ownership and retain immutable published assets
MariusStorhaug Oct 31, 2026
0a0119c
Exclude ambiguous recovery refs and serialize environment-sensitive a…
MariusStorhaug Oct 31, 2026
8e007ff
Compose effective default bumps with complete source-safe recovery
MariusStorhaug Oct 31, 2026
51de598
Honor module publication skip and validate all publication schema keys
MariusStorhaug Oct 31, 2026
2c77a09
Respect case-sensitive branches in release PR associations
MariusStorhaug Oct 31, 2026
d06a877
Bind prerelease retries and cleanup to immutable PR ownership
MariusStorhaug Oct 31, 2026
cd5cb21
Refresh configurable defaults against the frozen recovery safety cont…
MariusStorhaug Oct 31, 2026
0fb4234
Keep documented default settings valid against the publication schema
MariusStorhaug Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/actions/Cleanup-PSModulePrereleases/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Cleanup-PSModulePrereleases

Removes prerelease GitHub releases and their tags only when cleanup ownership is established. Source-bound
prereleases use the final controller-owned footer written by `Release-PSModule`: the exact base repository and PR
number must match, and the actual tag must resolve to its recorded immutable commit.

Legacy releases without that footer are retained. A branch or `target_commitish` hint cannot prove which PR or
repository produced a record, even when its short name matches the current PR.
Substring matches, sanitized-name collisions, numeric suffixes, stable releases, and another branch's records do not
establish cleanup ownership.

The normalized `PullRequest` JSON and event-payload fallback retain their existing interfaces. `ReleaseTag` is always
excluded, and `WhatIf: true` reports commands without deleting anything. SHA-targeted records without verified
controller provenance are retained. Malformed records, conflicting tag/source provenance, or a failed GitHub lookup
stop cleanup instead of guessing.

The existing normalized `PullRequest` context supplies the source PR number. Missing or invalid PR identity fails
explicitly; older unproven release records are not rewritten or deleted by adopting this version.

The action fetches every release page through the GitHub API. Tests replace the GitHub CLI with a local function, so
ownership, retained-tag, collision, and dry-run checks do not create or delete real releases.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
$ErrorActionPreference = 'Stop'
Import-Module -Name (
Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath '..', 'Release-PSModule', 'src', 'Release-PSModule.Helpers.psm1'
) -Force

function Get-PSModulePrereleaseForCleanup {
<#
.SYNOPSIS
Select prereleases with verified producing PR and source ownership.

.DESCRIPTION
Uses controller-owned repository, PR, and commit provenance rather than
branch or tag substrings. Stable and unverified legacy releases, another
PR's releases, and the supplied published tag are never selected.

.EXAMPLE
Get-PSModulePrereleaseForCleanup -Release $releases -Repository 'PSModule/MyModule' `
-PullRequestNumber 42 -GetTagCommit $readTagCommit

Returns only verified prereleases produced for MyModule PR 42.

.INPUTS
None

You can't pipe objects to Get-PSModulePrereleaseForCleanup.

.OUTPUTS
System.Management.Automation.PSCustomObject

A release eligible for branch-scoped cleanup.
#>
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
# The base repository in owner/name form.
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Repository,

# The source PR number within the base repository.
[Parameter()]
[long] $PullRequestNumber,

# Returns the exact immutable commit referenced by a release tag.
[Parameter()]
[scriptblock] $GetTagCommit,

# Release objects with tagName, isPrerelease, and targetCommitish.
[Parameter()]
[object[]] $Release,

# The tag created by the current publication, which must be retained.
[Parameter()]
[string] $ReleaseTag
)

foreach ($candidate in $Release) {
if ($null -eq $candidate -or -not $candidate.isPrerelease -or $candidate.tagName -ceq $ReleaseTag) {
continue
}
if ([string]::IsNullOrWhiteSpace($candidate.tagName) -or
[string]::IsNullOrWhiteSpace($candidate.targetCommitish)) {
throw 'Cannot determine prerelease cleanup ownership: a release is missing its tag or targetCommitish.'
}
$ownership = Get-PSModulePrereleaseOwnership -Body ([string]$candidate.body)
if ($null -ne $ownership) {
if ($ownership.Repository -ne $Repository -or $ownership.PullRequest -ne $PullRequestNumber) {
continue
}
if ($candidate.targetCommitish -cne $ownership.CommitSha) {
Write-Verbose "Retaining [$($candidate.tagName)] because its creation target does not bind the ownership source."
continue
}
if ($null -eq $GetTagCommit) {
throw 'Immutable tag lookup is required to verify published prerelease ownership.'
}
$tagCommit = & $GetTagCommit $candidate.tagName
if ($tagCommit -cne $ownership.CommitSha) {
throw "Prerelease tag [$($candidate.tagName)] no longer matches its recorded source. Refusing cleanup."
}
$candidate
} else {
Write-Verbose "Retaining legacy release [$($candidate.tagName)] without producing PR/source ownership evidence."
}
}
}
82 changes: 55 additions & 27 deletions .github/actions/Cleanup-PSModulePrereleases/src/cleanup.ps1
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
[CmdletBinding()]
[CmdletBinding()]
param()

$ErrorActionPreference = 'Stop'
$PSStyle.OutputRendering = 'Ansi'

Import-Module -Name 'PSModule' -Force
Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'Cleanup-PSModulePrereleases.Helpers.psm1') -Force

#region Load inputs
LogGroup 'Load inputs' {
Set-GitHubLogGroup 'Load inputs' {
$whatIf = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_WhatIf -eq 'true'

$pullRequestJson = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_INPUT_PullRequest
$prHeadRef = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') {
($pullRequestJson | ConvertFrom-Json).HeadRef
$pullRequest = if (-not [string]::IsNullOrWhiteSpace($pullRequestJson) -and $pullRequestJson -ne 'null') {
$pullRequestJson | ConvertFrom-Json
} else {
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
$githubEvent = $githubEventJson | ConvertFrom-Json
$githubEvent.pull_request.head.ref
[pscustomobject]@{
Number = $githubEvent.pull_request.number
HeadRef = $githubEvent.pull_request.head.ref
HeadRepository = $githubEvent.pull_request.head.repo.full_name
}
}
$prHeadRef = $pullRequest.HeadRef

if ([string]::IsNullOrWhiteSpace($prHeadRef)) {
Write-Host '::notice::No pull request head ref is available. Nothing to cleanup.'
exit 0
}
$prereleaseName = $prHeadRef -replace '[^a-zA-Z0-9]'

if ([string]::IsNullOrWhiteSpace($prereleaseName)) {
Write-Host "No prerelease tag derivable from PR head ref [$prHeadRef]. Nothing to cleanup."
exit 0
if ([long]$pullRequest.Number -lt 1) {
throw 'A positive source PR number is required for verified prerelease cleanup.'
}

Write-Host "PR head ref: [$prHeadRef]"
Write-Host "Prerelease name: [$prereleaseName]"
Write-Host "WhatIf: [$whatIf]"

$publishedReleaseTag = $env:PSMODULE_CLEANUP_PSMODULEPRERELEASES_CONTEXT_ReleaseTag
Expand All @@ -41,21 +43,48 @@ LogGroup 'Load inputs' {
#endregion Load inputs

#region Find prereleases to cleanup
LogGroup "Find prereleases to cleanup for [$prereleaseName]" {
$releaseListOutput = gh release list --json 'createdAt,isDraft,isLatest,isPrerelease,name,publishedAt,tagName'
Set-GitHubLogGroup "Find prereleases to cleanup for [$prHeadRef]" {
$releaseListOutput = gh api "repos/$env:GITHUB_REPOSITORY/releases?per_page=100" --paginate `
--jq '.[] | {tagName: .tag_name, isPrerelease: .prerelease, targetCommitish: .target_commitish, body: .body}'
if ($LASTEXITCODE -ne 0) {
Write-Error 'Failed to list releases for the repository.'
exit $LASTEXITCODE
throw "Failed to list releases for the repository (gh exited $LASTEXITCODE)."
}
$releases = $releaseListOutput | ConvertFrom-Json

$prereleasesToCleanup = $releases | Where-Object {
$_.isPrerelease -eq $true -and $_.tagName -like "*$prereleaseName*" -and $_.tagName -ne $publishedReleaseTag
$releases = @($releaseListOutput | ForEach-Object { $_ | ConvertFrom-Json })
$parameters = @{
Repository = $env:GITHUB_REPOSITORY
PullRequestNumber = [long]$pullRequest.Number
Release = $releases
ReleaseTag = $publishedReleaseTag
GetTagCommit = {
param($Tag)
$tagParameters = @{
Tag = $Tag
GetTagReference = {
param($TagName)
$encodedTag = [Uri]::EscapeDataString($TagName)
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/ref/tags/$encodedTag"
if ($LASTEXITCODE -ne 0) {
throw "Cannot verify cleanup tag [$TagName] (gh exited $LASTEXITCODE)."
}
($response | ConvertFrom-Json -ErrorAction Stop).object
}
GetTagObject = {
param($TagSha)
$response = gh api "repos/$env:GITHUB_REPOSITORY/git/tags/$TagSha"
if ($LASTEXITCODE -ne 0) {
throw "Cannot verify cleanup tag object [$TagSha] (gh exited $LASTEXITCODE)."
}
($response | ConvertFrom-Json -ErrorAction Stop).object
}
}
Resolve-PSModuleReleaseTagCommit @tagParameters
}
}
$prereleasesToCleanup = Get-PSModulePrereleaseForCleanup @parameters
$tagsToDelete = @($prereleasesToCleanup | ForEach-Object { $_.tagName } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

if ($tagsToDelete.Count -eq 0) {
Write-Host "No prereleases found to cleanup for [$prereleaseName]."
Write-Host "No prereleases have verified ownership for [$env:GITHUB_REPOSITORY#$($pullRequest.Number)]."
return
}

Expand All @@ -65,21 +94,20 @@ LogGroup "Find prereleases to cleanup for [$prereleaseName]" {
#endregion Find prereleases to cleanup

#region Delete prereleases
LogGroup "Delete prereleases for [$prereleaseName]" {
Set-GitHubLogGroup "Delete prereleases for [$prHeadRef]" {
foreach ($tag in $tagsToDelete) {
Write-Host "Deleting prerelease: [$tag]"
if ($whatIf) {
Write-Host "WhatIf: gh release delete $tag --cleanup-tag --yes"
Write-Host "WhatIf: gh release delete --cleanup-tag --yes -- $tag"
} else {
gh release delete $tag --cleanup-tag --yes
gh release delete --cleanup-tag --yes -- $tag
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to delete release [$tag]."
exit $LASTEXITCODE
throw "Failed to delete release [$tag] (gh exited $LASTEXITCODE)."
}
Write-Host "Successfully deleted release [$tag]."
}
}

Write-Host "::notice::Cleaned up $($tagsToDelete.Count) prerelease(s) for [$prereleaseName]."
Write-Host "::notice::Cleaned up $($tagsToDelete.Count) prerelease(s) for [$prHeadRef]."
}
#endregion Delete prereleases
Loading
Loading