From 23cac16a99d989c6a8091b04458c4961e35d865e Mon Sep 17 00:00:00 2001 From: omaiesh <98556907+omaiesh@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:28:39 +0200 Subject: [PATCH 1/2] ci: publish PyPI download badges from a reliable endpoint The README's download badges break regularly. shields.io's pypi/dm badge reads pypistats.org's /recent endpoint, which is aggressively rate limited; when it refuses, shields renders "rate limited by upstream service" and GitHub's camo proxy caches that error image, so the badge stays broken long after the upstream recovers. Measured on 20cfc30f: the camo-cached image for rosetta-mcp showed the rate-limit text while a direct fetch returned 594/month. Read pypistats' /overall series instead, which answers reliably, sum the last 30 days excluding mirrors, and publish a shields endpoint JSON to an orphan `badges` branch. Counts match what shields reports when it does work: rosetta-mcp 594, rosetta-cli 540. Mirrors stay excluded deliberately. Including them roughly quadruples the number (2442 and 2008) without a single extra install, which is why pepy.tech reports "2k" for both packages. Runs daily, on demand, and on changes to its own files. No secrets needed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/download_badges.py | 87 +++++++++++++++++++++++++++ .github/workflows/download-badges.yml | 73 ++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 .github/scripts/download_badges.py create mode 100644 .github/workflows/download-badges.yml diff --git a/.github/scripts/download_badges.py b/.github/scripts/download_badges.py new file mode 100644 index 00000000..0608c08e --- /dev/null +++ b/.github/scripts/download_badges.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Build shields.io endpoint JSON for PyPI monthly downloads. + +pypistats.org exposes two relevant endpoints. `/recent` is the one shields.io +reads for its own `pypi/dm` badge, and it is aggressively rate limited: when it +refuses, shields renders "rate limited by upstream service" and GitHub's camo +proxy caches that error image for hours. `/overall` returns the daily series and +answers reliably, so this script sums the last 30 days from it instead. + +Counts exclude mirrors, which is what shields.io reports and what represents +real installs. Including mirrors roughly quadruples the number. + +Usage: download_badges.py +""" + +import json +import sys +import time +import urllib.error +import urllib.request +from datetime import date, timedelta + +API = "https://pypistats.org/api/packages/{pkg}/overall?mirrors=false" +WINDOW_DAYS = 30 +PACKAGES = { + "rosetta-mcp": "MCP downloads", + "rosetta-cli": "CLI downloads", +} + + +def fetch(pkg: str, attempts: int = 5) -> dict: + """GET the overall series, retrying with linear backoff on transient errors.""" + url = API.format(pkg=pkg) + req = urllib.request.Request(url, headers={"User-Agent": "rosetta-badges/1.0"}) + last = None + for attempt in range(1, attempts + 1): + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.load(resp) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last = exc + if attempt < attempts: + time.sleep(5 * attempt) + raise RuntimeError(f"{pkg}: pypistats unreachable after {attempts} attempts: {last}") + + +def monthly(pkg: str) -> int: + cutoff = (date.today() - timedelta(days=WINDOW_DAYS)).isoformat() + rows = fetch(pkg)["data"] + return sum(r["downloads"] for r in rows if r["date"] >= cutoff) + + +def human(n: int) -> str: + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M".replace(".0M", "M") + if n >= 10_000: + return f"{n // 1000}k" + if n >= 1_000: + return f"{n / 1000:.1f}k".replace(".0k", "k") + return str(n) + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + out = sys.argv[1] + + for pkg, label in PACKAGES.items(): + count = monthly(pkg) + payload = { + "schemaVersion": 1, + "label": label, + "message": f"{human(count)}/month", + "color": "blue", + } + path = f"{out}/{pkg}.json" + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + print(f"{pkg}: {count} downloads in {WINDOW_DAYS}d -> {path}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/download-badges.yml b/.github/workflows/download-badges.yml new file mode 100644 index 00000000..eb97d972 --- /dev/null +++ b/.github/workflows/download-badges.yml @@ -0,0 +1,73 @@ +name: Download Badges + +# Refreshes the PyPI monthly-download badges shown in README.md. +# +# Why this exists: shields.io's own `pypi/dm` badge reads pypistats.org's +# `/recent` endpoint, which is aggressively rate limited. When it refuses, +# shields renders "rate limited by upstream service" and GitHub's camo proxy +# caches that error image, so the badge stays broken long after the upstream +# recovers. This job reads `/overall` instead, which answers reliably, and +# publishes a shields endpoint JSON to the orphan `badges` branch. +# +# Counts exclude mirrors, matching what shields.io reports. +# +# No secrets required: pushes with the default GITHUB_TOKEN. + +on: + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + push: + branches: + - main + paths: + - '.github/workflows/download-badges.yml' + - '.github/scripts/download_badges.py' + +permissions: + contents: write + +concurrency: + group: download-badges + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Build badge JSON + run: | + mkdir -p "$RUNNER_TEMP/badges" + python .github/scripts/download_badges.py "$RUNNER_TEMP/badges" + + - name: Publish to badges branch + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code --heads origin badges >/dev/null 2>&1; then + git fetch origin badges --depth=1 + git switch --force badges + else + git switch --orphan badges + git rm -rf . --quiet || true + fi + + cp -f "$RUNNER_TEMP"/badges/*.json . + + git add ./*.json + if git diff --cached --quiet; then + echo "No change in download counts; nothing to publish." + else + git commit -m "chore: refresh download badges" + git push origin badges + fi From d8b37e6885a0df50e290bf40fffa43d5dd4d3437 Mon Sep 17 00:00:00 2001 From: omaiesh <98556907+omaiesh@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:35:08 +0200 Subject: [PATCH 2/2] ci: drop dead git rm from the orphan-branch path git switch --orphan already starts with an empty tree, unlike git checkout --orphan, so the git rm only emitted a fatal pathspec error into the job log. Caught by simulating the step against a local bare repository. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/download-badges.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/download-badges.yml b/.github/workflows/download-badges.yml index eb97d972..9531254b 100644 --- a/.github/workflows/download-badges.yml +++ b/.github/workflows/download-badges.yml @@ -58,8 +58,8 @@ jobs: git fetch origin badges --depth=1 git switch --force badges else + # git switch --orphan starts with an empty tree, unlike git checkout --orphan git switch --orphan badges - git rm -rf . --quiet || true fi cp -f "$RUNNER_TEMP"/badges/*.json .