From 0ee329101d038f85f4743d8d5af77a19a33c6947 Mon Sep 17 00:00:00 2001 From: "Vladyslav G." Date: Wed, 2 Sep 2026 04:37:21 +0200 Subject: [PATCH] ci: prune old release pages after a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The releases page is for finding the current version; CHANGELOG.md is the ledger, and semantic-release writes the same notes into both. Keeping every release forever only makes the page harder to read. Every major stays, along with the last two feature releases and the last three fixes. Tags are never touched — Packagist resolves a version from the tag, and a lock file naming one has to stay installable years later, so `gh release delete` is deliberately called without --cleanup-tag. --- .github/prune-releases.py | 62 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 8 +++++ 2 files changed, 70 insertions(+) create mode 100644 .github/prune-releases.py diff --git a/.github/prune-releases.py b/.github/prune-releases.py new file mode 100644 index 0000000..6d51239 --- /dev/null +++ b/.github/prune-releases.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Keeps the releases page short without touching a single tag. + +Packagist resolves a version from the git tag, not from the GitHub release, and +a lock file names a version someone has to be able to install years from now. +So this removes only the release page: `gh release delete` leaves the tag alone +unless asked for --cleanup-tag, which is exactly what must never happen here. +Nothing is lost either way — semantic-release writes the same notes into +CHANGELOG.md, which is in the repository. + +Kept: every major, the last two feature releases, the last three fixes. +""" + +import json +import subprocess + +KEEP_FEATURE = 2 +KEEP_FIX = 3 + + +def gh(*args: str) -> str: + return subprocess.run(['gh', *args], capture_output=True, text=True, check=True).stdout + + +def version(tag: str) -> tuple[int, int, int] | None: + try: + major, minor, patch = (int(part) for part in tag.lstrip('v').split('.')[:3]) + except ValueError: + # Anything not shaped like a version is left alone rather than guessed at. + return None + + return major, minor, patch + + +def main() -> None: + listed = json.loads(gh('release', 'list', '--limit', '200', '--json', 'tagName,isLatest')) + releases = [release for release in listed if version(release['tagName'])] + releases.sort(key=lambda release: version(release['tagName']), reverse=True) + + majors = [r for r in releases if version(r['tagName'])[1:] == (0, 0)] + features = [r for r in releases if version(r['tagName'])[2] == 0 and version(r['tagName'])[1] != 0] + fixes = [r for r in releases if version(r['tagName'])[2] != 0] + + keep = {r['tagName'] for r in majors} + keep |= {r['tagName'] for r in features[:KEEP_FEATURE]} + keep |= {r['tagName'] for r in fixes[:KEEP_FIX]} + # Whatever GitHub is showing as current stays, whichever bucket it fell in. + keep |= {r['tagName'] for r in releases if r['isLatest']} + + for release in releases: + tag = release['tagName'] + + if tag in keep: + continue + + print(f'Removing the release page for {tag}; the tag itself stays.') + gh('release', 'delete', tag, '--yes') + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e692eae..19fe38c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,3 +40,11 @@ jobs: conventional-changelog-conventionalcommits@8 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The releases page is for finding the current version, not for keeping a + # ledger — CHANGELOG.md is the ledger. Tags are never touched: Packagist + # resolves versions from them, and a lock file has to stay installable. + - name: Prune old release pages + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/prune-releases.py