From c012a70e40309f1222025409627ee051e145c1bc Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:41:08 -0700 Subject: [PATCH] Stop deleting GitHub releases A retention step deleted every older patch release within a minor so only the newest survived. Package registries pin the release asset URL for their own version and keep approved versions forever, so this broke installs permanently. The approved Chocolatey package rackstack 1.99.0 downloads .../releases/download/v1.99.0/RackStack.exe via Get-ChocolateyWebFile. Retention deleted that release when v1.99.1 shipped on 2026-05-21, so `choco install rackstack` has returned 404 for roughly two months. It cannot be repaired: no archived EXE exists and ps2exe is not reproducible, so the published checksum can never be satisfied again. The remedy is getting a current version approved. The same step also broke moderation in progress - v1.122.2 failed automated validation with "unable to find a package" because v1.122.3 published while v1.122.2 was still queued, deleting the release out from under the reviewer. dist/winget/README.md already documented the hazard for winget, filed as a temporary annoyance rather than the permanent breakage it turned out to be. Release assets on a public repo do not count against the Actions artifact storage quota, so nothing was being saved by deleting them. - Remove the retention step, with the incidents recorded inline so it is not re-added as a cleanup - Add Run-Tests section 206, failing the suite if any workflow calls `gh release delete` or `--cleanup-tag`. Verified by appending a deletion line to ci.yml and confirming the suite goes red. - Rewrite the winget README section to describe the permanent hazard --- .github/workflows/ci.yml | 58 ++++++++++++++-------------------------- README.md | 2 +- Tests/Run-Tests.ps1 | 47 ++++++++++++++++++++++++++++++++ dist/winget/README.md | 23 +++++++++------- 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be0482f..b9b0232 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/README.md b/README.md index c443cae..6223721 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5426 structural tests + 5428 structural tests Pester 312 tests SLSA Level 3

diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index 5343871..7651695 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -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/ + ($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) # ============================================================================ diff --git a/dist/winget/README.md b/dist/winget/README.md index 311de4b..558a6a6 100644 --- a/dist/winget/README.md +++ b/dist/winget/README.md @@ -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 `. -## 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`.