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`.