From b43f149753c1a0d6c09634809f3e9ed3a7e94273 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 4 Nov 2026 01:26:45 +0000 Subject: [PATCH 1/3] Key catalogue releases by version and preserve history Send the plain semver version as the release tag instead of the git tag: the API and site sort and compare releases by semver tag, and the site derives the editable version from it. Prepend to the published release history (same-tag re-runs replace in place, capped at 100) instead of submitting only the newest entry. --- .github/scripts/build_payload.py | 89 +++++++++++++++++++++++++++++++- .github/workflows/publish.yml | 2 + README.md | 7 ++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/.github/scripts/build_payload.py b/.github/scripts/build_payload.py index 3618d44..a740652 100644 --- a/.github/scripts/build_payload.py +++ b/.github/scripts/build_payload.py @@ -9,6 +9,14 @@ * ``payload-update.json`` for PUT /extensions/{id} (identical except the ``id``, which that endpoint rejects as an unknown field). +The new release entry is keyed by the plain version (``"0.8.9"``), not the +git tag (``"branding-v0.8.9"``): the catalogue sorts and compares releases +by semver tag on every read, and the extensions site derives the editable +version from it. The entry is prepended to the extension's already +published releases (fetched from the API when ``API_TOKEN`` is set) so the +catalogue keeps its changelog history; without a token only the new entry +is submitted. + The catalogue API speaks its own type vocabulary, so the manifest type is translated here ("module" -> "mod"); unknown types fail loudly rather than being passed through. @@ -21,10 +29,16 @@ import re import subprocess import sys +import urllib.error +import urllib.request MAX_CONTENT_BYTES = 256 * 1024 +# Catalogue cap on releases per extension (ExtensionContentSchema); older +# entries past this are dropped from the submitted history. +MAX_RELEASES = 100 + # Manifest (manifest.json v1) type -> catalogue API type. TYPE_MAP = {"module": "mod"} @@ -70,6 +84,73 @@ def min_core_version(constraint: object, manifest_path: str) -> str: return match.group(1) +def fetch_existing_releases(ext_id: str) -> list: + """Fetch the extension's currently published releases for merge history. + + Returns [] for a new extension (API 404) or when no API token is + configured (offline use). Any other failure is fatal: silently + submitting without history would wipe the catalogue's changelog. + Entries are reduced to the release fields the catalogue schema + accepts, so a newer API response shape can never fail validation. + """ + api_token = os.environ.get("API_TOKEN", "") + api_base = os.environ.get("API_BASE_URL", "") + if not api_token or not api_base: + return [] + + http_request = urllib.request.Request( + f"{api_base.rstrip('/')}/extensions/{ext_id}", + headers={"Authorization": f"Bearer {api_token}"}, + ) + try: + with urllib.request.urlopen(http_request, timeout=30) as response: + body = json.load(response) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return [] + fail(f"Could not fetch existing releases for '{ext_id}': HTTP {exc.code}") + except Exception as exc: + fail(f"Could not fetch existing releases for '{ext_id}': {exc}") + result = body.get("result") if isinstance(body, dict) else None + releases = result.get("releases") if isinstance(result, dict) else None + if not isinstance(releases, list): + fail(f"Catalogue response for '{ext_id}' has no releases list") + known = [] + for entry in releases: + if not isinstance(entry, dict): + fail(f"Catalogue response for '{ext_id}' has a malformed release entry") + known.append( + { + key: entry[key] + for key in ( + "tag", + "date", + "download_url", + "changelog_url", + "min_fossbilling_version", + ) + if key in entry + } + ) + return known + + +def merge_releases(new_release: dict, ext_id: str) -> list: + """Prepend the new release to the published history. + + Same-tag re-runs replace in place so a retried publish is idempotent; + the catalogue caps the list at its 100-release maximum, oldest dropped. + Ordering beyond newest-first does not matter here: the API re-sorts by + semver tag on every read. + """ + merged = [new_release] + merged.extend( + entry for entry in fetch_existing_releases(ext_id) + if entry.get("tag") != new_release["tag"] + ) + return merged[:MAX_RELEASES] + + def main() -> None: ext_id, version, tag = sys.argv[1], sys.argv[2], sys.argv[3] if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", ext_id) or len(ext_id) > 200: @@ -143,8 +224,12 @@ def main() -> None: ) download_url = zips[0] + # The catalogue keys releases by plain semver version, not by git tag: + # sorting, latest-release resolution, and the directory's edit form + # all compare `tag` with semver. The git tag stays reachable through + # the changelog and download URLs. new_release: dict = { - "tag": tag, + "tag": version, "date": published_at, "download_url": download_url, "min_fossbilling_version": min_version, @@ -159,7 +244,7 @@ def main() -> None: "type": TYPE_MAP[meta["type"]], "name": meta["name"], "description": meta["description"], - "releases": [new_release], + "releases": merge_releases(new_release, ext_id), "website": meta["homepage_url"], "license": {"name": license_name, "spdx_id": license_name}, "readme": readme, diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b6a7d13..d2ac9fa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -72,6 +72,8 @@ jobs: - name: Build extension payload env: GH_TOKEN: ${{ github.token }} + API_TOKEN: ${{ steps.token.outputs.access_token }} + API_BASE_URL: ${{ env.API_BASE_URL }} run: python3 .github/scripts/build_payload.py "${{ steps.target.outputs.extension_id }}" "${{ steps.target.outputs.version }}" "${{ steps.target.outputs.tag }}" - name: Submit revision (create or update) diff --git a/README.md b/README.md index af8321c..fef337f 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,11 @@ Only simple `>=X.Y.Z` fossbilling constraints can be published; anything fancier fails the payload build with an explicit error. `icon_url` is only submitted when it is an absolute `https://` URL. -The release tag becomes the catalogue release entry: date and changelog link -come from the GitHub release itself. +The release becomes a new catalogue release entry keyed by the version: +date, changelog link, and the distributable asset come from the GitHub +release itself, and the entry is prepended to the extension's release +history. A manual re-run via `workflow_dispatch` with the same tag +replaces the entry in place. ## Secrets From f94fd2f9cf2a9296d2e6e83a757ecc5cb38e1c13 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 4 Nov 2026 01:32:19 +0000 Subject: [PATCH 2/3] Collapse legacy git-tag twins when merging release history A pre-fix entry keyed by git tag counts as the same release as its version, so re-publishing replaces the twin instead of doubling it. Non-matching legacy entries are preserved. --- .github/scripts/build_payload.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/build_payload.py b/.github/scripts/build_payload.py index a740652..dcee8aa 100644 --- a/.github/scripts/build_payload.py +++ b/.github/scripts/build_payload.py @@ -140,17 +140,31 @@ def merge_releases(new_release: dict, ext_id: str) -> list: Same-tag re-runs replace in place so a retried publish is idempotent; the catalogue caps the list at its 100-release maximum, oldest dropped. + A pre-fix entry keyed by git tag (`-v`) counts as the same + release as its version, so re-publishing that version replaces the twin + instead of doubling it; other legacy entries are preserved as-is. Ordering beyond newest-first does not matter here: the API re-sorts by semver tag on every read. """ merged = [new_release] merged.extend( - entry for entry in fetch_existing_releases(ext_id) - if entry.get("tag") != new_release["tag"] + entry + for entry in fetch_existing_releases(ext_id) + if canonical_tag(entry.get("tag"), ext_id) != new_release["tag"] ) return merged[:MAX_RELEASES] +def canonical_tag(tag: object, ext_id: str) -> object: + """Collapse the pre-fix `-v` tag shape to its version. + + Comparison-only: stored entries are never rewritten here. + """ + if isinstance(tag, str) and tag.startswith(f"{ext_id}-v"): + return tag[len(ext_id) + 2 :] + return tag + + def main() -> None: ext_id, version, tag = sys.argv[1], sys.argv[2], sys.argv[3] if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", ext_id) or len(ext_id) > 200: From 7aee2eaefbcb4f116d0d529b6fe082e483a168ac Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Wed, 4 Nov 2026 01:34:23 +0000 Subject: [PATCH 3/3] Read canonical_tag before its caller --- .github/scripts/build_payload.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/scripts/build_payload.py b/.github/scripts/build_payload.py index dcee8aa..424eac8 100644 --- a/.github/scripts/build_payload.py +++ b/.github/scripts/build_payload.py @@ -135,6 +135,16 @@ def fetch_existing_releases(ext_id: str) -> list: return known +def canonical_tag(tag: object, ext_id: str) -> object: + """Collapse the pre-fix `-v` tag shape to its version. + + Comparison-only: stored entries are never rewritten here. + """ + if isinstance(tag, str) and tag.startswith(f"{ext_id}-v"): + return tag[len(ext_id) + 2 :] + return tag + + def merge_releases(new_release: dict, ext_id: str) -> list: """Prepend the new release to the published history. @@ -155,16 +165,6 @@ def merge_releases(new_release: dict, ext_id: str) -> list: return merged[:MAX_RELEASES] -def canonical_tag(tag: object, ext_id: str) -> object: - """Collapse the pre-fix `-v` tag shape to its version. - - Comparison-only: stored entries are never rewritten here. - """ - if isinstance(tag, str) and tag.startswith(f"{ext_id}-v"): - return tag[len(ext_id) + 2 :] - return tag - - def main() -> None: ext_id, version, tag = sys.argv[1], sys.argv[2], sys.argv[3] if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", ext_id) or len(ext_id) > 200: