Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 20 additions & 38 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -538,41 +538,23 @@ jobs:
Pop-Location
exit 0

- name: Apply retention rule (only latest z in minor survives)
if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$ver = '${{ steps.vercheck.outputs.version }}'
$vParts = $ver -split '\.'
if ($vParts.Length -ne 3) {
Write-Host "Unexpected version format '$ver' — skipping retention cleanup"
exit 0
}
$thisMinor = "$($vParts[0]).$($vParts[1])" # e.g. "1.98"
# Enumerate ALL existing releases (not just immediate prev) so we correctly
# collapse the 1.98.x lineup down to just v1.98.8 even if v1.98.7 was missed
# and v1.98.6 is still the latest existing release. End-state: only the new
# `$ver` release exists in this minor.
$allReleases = gh release list --json tagName --limit 200 | ConvertFrom-Json
$global:LASTEXITCODE = 0
$toDelete = @()
foreach ($r in $allReleases) {
$tag = $r.tagName
$tagVer = $tag -replace '^v', ''
$tagParts = $tagVer -split '\.'
if ($tagParts.Length -eq 3 -and "$($tagParts[0]).$($tagParts[1])" -eq $thisMinor -and $tagVer -ne $ver) {
$toDelete += $tag
}
}
if ($toDelete.Count -eq 0) {
Write-Host "Retention: no older releases in minor $thisMinor — nothing to delete"
} else {
Write-Host "Retention: deleting $($toDelete.Count) older $thisMinor.x release(s) so only v$ver survives"
foreach ($tag in $toDelete) {
Write-Host " -> gh release delete $tag --cleanup-tag"
gh release delete $tag --yes --cleanup-tag 2>$null
$global:LASTEXITCODE = 0
}
}
exit 0
# NO RELEASE RETENTION / DELETION STEP — deliberately removed, do not re-add.
#
# A retention step used to delete every older patch release within the same minor so
# that only the newest survived. That silently broke the package managers, because a
# published package manifest pins the release asset URL for its own version and those
# registries keep approved versions forever:
#
# - Chocolatey: the approved rackstack 1.99.0 package downloads
# .../releases/download/v1.99.0/RackStack.exe via Get-ChocolateyWebFile. Retention
# deleted that release when v1.99.1 shipped, so `choco install rackstack` returned
# 404 for roughly two months before anyone noticed (found 2026-07-29).
# - Chocolatey moderation: v1.122.2 failed automated validation with "unable to find
# a package" because v1.122.3 published while v1.122.2 was still in the review
# queue, and retention deleted the release out from under the moderator.
# - winget: the same hazard was already documented in dist/winget/README.md.
#
# GitHub release assets on a public repo do not count against the Actions artifact
# storage quota, so there is no storage argument for deleting them. If disk hygiene
# ever matters, prune Actions *artifacts* — never releases that a published package
# points at. Run-Tests section 206 asserts this workflow contains no release deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<a href="https://www.bestpractices.dev/projects/12921"><img alt="OpenSSF Best Practices" src="https://www.bestpractices.dev/projects/12921/badge"></a>
<a href="https://codecov.io/gh/TheAbider/RackStack"><img alt="codecov" src="https://codecov.io/gh/TheAbider/RackStack/branch/master/graph/badge.svg"></a>
<img alt="PSScriptAnalyzer 0 errors" src="https://img.shields.io/badge/PSScriptAnalyzer-0%20errors-brightgreen">
<img alt="5426 structural tests" src="https://img.shields.io/badge/structural%20tests-5426-brightgreen">
<img alt="5428 structural tests" src="https://img.shields.io/badge/structural%20tests-5428-brightgreen">
<img alt="Pester 312 tests" src="https://img.shields.io/badge/Pester-312%20tests-brightgreen">
<img alt="SLSA Level 3" src="https://slsa.dev/images/gh-badge-level3.svg">
</p>
Expand Down
47 changes: 47 additions & 0 deletions Tests/Run-Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -10470,6 +10470,53 @@ catch {
Write-TestResult "Release Version Consistency Tests" $false $_.Exception.Message
}

# ============================================================================
# SECTION 206: RELEASES ARE NEVER DELETED (package managers pin release URLs)
# ============================================================================
# A published package manifest pins the release asset URL for its own version, and the
# registries keep approved versions forever. Deleting a release therefore breaks installs
# permanently and irrecoverably — the EXE cannot be rebuilt byte-identically, so the
# published checksum can never be satisfied again.
#
# This is not hypothetical. The approved Chocolatey package rackstack 1.99.0 downloads
# .../releases/download/v1.99.0/RackStack.exe; a retention step deleted that release when
# v1.99.1 shipped, and `choco install rackstack` returned 404 for about two months before
# it was noticed (2026-07-29). The same step also broke the v1.122.2 Chocolatey moderation
# submission mid-review. Prune Actions artifacts if storage ever matters — never releases.
Write-SectionHeader "SECTION 206: RELEASES ARE NEVER DELETED"

try {
$wfDir206 = Join-Path $script:ModuleRoot '.github\workflows'
if (Test-Path -LiteralPath $wfDir206) {
$deleters206 = @(
Get-ChildItem -Path $wfDir206 -Filter '*.yml' -File |
Where-Object {
$raw = Get-Content $_.FullName -Raw
# `gh release delete`, or the REST equivalent against /releases/<id>
($raw -match 'gh\s+release\s+delete') -or
($raw -match '(?i)-X\s+DELETE[^\r\n]*\/releases\/')
} | ForEach-Object { $_.Name }
)
Write-TestResult "Releases: no workflow deletes a GitHub release" `
($deleters206.Count -eq 0) $(if ($deleters206.Count) { "found in: $($deleters206 -join ', ')" } else { "" })

# The tag must survive too — a deleted tag breaks source-based installs and the
# cosign certificate-identity that references the ref.
$tagKillers206 = @(
Get-ChildItem -Path $wfDir206 -Filter '*.yml' -File |
Where-Object { (Get-Content $_.FullName -Raw) -match '--cleanup-tag' } |
ForEach-Object { $_.Name }
)
Write-TestResult "Releases: no workflow deletes a release tag" `
($tagKillers206.Count -eq 0) $(if ($tagKillers206.Count) { "found in: $($tagKillers206 -join ', ')" } else { "" })
} else {
Write-TestResult "Releases: workflow directory present" -Skipped -Message "no .github/workflows in this layout"
}
}
catch {
Write-TestResult "Release Retention Tests" $false $_.Exception.Message
}

# ============================================================================
# SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase)
# ============================================================================
Expand Down
23 changes: 14 additions & 9 deletions dist/winget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,17 @@ ci.yml step -- do not hand-maintain these files after that.
update the version, URL, SHA-256, and `ReleaseDate` in all three
files, then run `winget validate --manifest <folder>`.

## Caveat: release retention vs. winget URLs

The ci.yml retention step deletes the previous patch release within a
minor when a new one publishes. The winget manifest for a version points
at that version's release asset URL, so when the next patch ships, the
current winget manifest's InstallerUrl goes dead until the auto-submitted
update PR for the new version merges in winget-pkgs (moderation can take
days). During that window `winget install TheAbider.RackStack` fails
hash/download; installs recover as soon as the update PR merges.
## Release retention: releases are never deleted

Earlier versions of `ci.yml` deleted the previous patch release within a
minor when a new one published, which meant a published winget manifest's
`InstallerUrl` went dead as soon as the next patch shipped.

That retention step has been removed. Release assets are permanent, because
every package registry pins the asset URL for its own version and keeps
approved versions indefinitely. The same defect silently broke the approved
Chocolatey package (`choco install rackstack` returned 404 for about two
months) before it was found on 2026-07-29.

Do not reintroduce release deletion. `Run-Tests` section 206 fails the suite
if any workflow calls `gh release delete` or `--cleanup-tag`.