From 1fba894a9b63c4a641326977c35f0ca4c5db32d3 Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 11 Sep 2026 20:23:32 +0530 Subject: [PATCH 1/2] Badge row: clones first, downloads and fuzz added, pyversions, ruff and mypy dropped The row is generated in ctrlrun-docs by tools/docs_audit/render_badges.py and pasted here; this is the paste, the test that pins it, and the workflow behind the one new number this repository produces itself. `clones` has no public source: GitHub's traffic API needs push access and keeps fourteen days. traffic.yml reads it daily with TRAFFIC_TOKEN, merges each day into clones-history.json on the badges branch by date (assignment, not addition, so a re-run cannot double-count), and writes clones-badge.json from the sum. The badge links to the history file. Absent the secret it warns and exits 0, the same trade notify-docs makes. actions/checkout is a git clone, so this repository's own CI is in the figure; the generator's docstring says so. `downloads` is shields.io over pypistats, labelled per month. `fuzz` is the existing fuzz.yml, which test_fuzzing already requires to run on every push. Removed: pypi/pyversions (metadata, not a claim, and on the PyPI page one click away), ruff and mypy --strict (how the library is written is not what the first screen decides; scripts/check.sh still runs both and CI still has to call it). The test now requires their absence so a row that grew back fails. --- .github/workflows/traffic.yml | 129 ++++++++++++++++++++++++++++++++++ README.md | 6 +- tests/test_readme_assets.py | 12 +++- 3 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/traffic.yml diff --git a/.github/workflows/traffic.yml b/.github/workflows/traffic.yml new file mode 100644 index 0000000..26784f0 --- /dev/null +++ b/.github/workflows/traffic.yml @@ -0,0 +1,129 @@ +name: Traffic + +# The clone count behind the README's `clones` badge. +# +# GitHub publishes no clone badge, and for a reason worth respecting: `GET /repos/{owner}/{repo} +# /traffic/clones` needs *push* access and keeps only the last fourteen days. So a badge that +# read the API at render time would be impossible (no anonymous read) and a badge that read it +# once would be a fortnight's number wearing a lifetime's label. +# +# This job answers both: it runs daily, merges the fourteen days the API returns into +# `clones-history.json` on the orphan `badges` branch keyed by date, and writes +# `clones-badge.json` from the sum. The API is authoritative for the days it covers, so a day +# it reports overwrites the stored one rather than adding to it; a re-run therefore cannot +# double-count, and a missed day is recovered by any run in the next fortnight. The badge links +# to the history file, so the number is a sum of days a reader can re-add. +# +# `GITHUB_TOKEN` cannot do this: the `permissions:` block has no key that grants traffic access. +# It needs a token with `administration: read` (fine-grained) or `repo` (classic), stored as +# `TRAFFIC_TOKEN`. Absent that secret the job warns and exits 0, the same trade `notify-docs` +# in `ci.yml` makes: a missing optional secret leaves a badge stale, and a red run every night +# would teach us to ignore a red run. +on: + schedule: + - cron: "43 2 * * *" + workflow_dispatch: + +permissions: read-all + +jobs: + clones: + # A fork has its own traffic and no business publishing to this repository's badges branch. + if: github.repository == 'CTRLRun/ctrlrun' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Read the traffic API + id: traffic + env: + TRAFFIC_TOKEN: ${{ secrets.TRAFFIC_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + set -eu + if [ -z "${TRAFFIC_TOKEN:-}" ]; then + echo "::warning::TRAFFIC_TOKEN is not set; the clones badge keeps its last value" + echo "read=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + curl -sS -f \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $TRAFFIC_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/$REPOSITORY/traffic/clones" > clones-api.json + echo "read=true" >> "$GITHUB_OUTPUT" + + - name: Merge into the history and publish + if: steps.traffic.outputs.read == 'true' + run: | + set -eu + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # `actions/checkout` configures a single-branch refspec, so the destination ref is + # named explicitly; see the `badge` job in `ci.yml` for what the short form breaks. + if git fetch origin badges:refs/remotes/origin/badges 2>/dev/null; then + git switch -c badges refs/remotes/origin/badges + else + git switch --orphan badges + fi + # `clones-api.json` is untracked, so it survives either path, which is the only file + # that must. + python - <<'PY' + import json + from pathlib import Path + + fresh = json.loads(Path("clones-api.json").read_text(encoding="utf-8")) + history_path = Path("clones-history.json") + history = ( + json.loads(history_path.read_text(encoding="utf-8")) + if history_path.exists() + else {"days": {}} + ) + days = history.get("days", {}) + # The API's fourteen days are authoritative for those fourteen days: assignment, not + # addition, is what makes a second run of the same day idempotent. + for day in fresh.get("clones", []): + days[day["timestamp"][:10]] = { + "count": day["count"], + "uniques": day["uniques"], + } + days = dict(sorted(days.items())) + total = sum(day["count"] for day in days.values()) + uniques = sum(day["uniques"] for day in days.values()) + history_path.write_text( + json.dumps( + { + "since": next(iter(days), None), + "total": total, + "uniques": uniques, + "days": days, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + # A count, never an outcome, and labelled with what it counts: `clones` is git + # clones including CI and mirrors, not people. + Path("clones-badge.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "label": "clones", + "message": f"{total:,}", + "color": "B8730A", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + print(f"clones badge: {total:,} over {len(days)} days") + PY + test -s clones-badge.json + total=$(python -c 'import json;print(json.load(open("clones-badge.json"))["message"])') + git add -f clones-badge.json clones-history.json + git commit -m "clones: $total" || { echo "clones unchanged"; exit 0; } + git push origin badges diff --git a/README.md b/README.md index 8719264..fabb2ce 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,16 @@

+ Clones PyPI - Python versions + Downloads Docs CI CodeQL + Fuzz Tests CTRLRun verified OpenSSF Scorecard - Ruff - Checked with mypy --strict License

diff --git a/tests/test_readme_assets.py b/tests/test_readme_assets.py index 954337e..b0d1238 100644 --- a/tests/test_readme_assets.py +++ b/tests/test_readme_assets.py @@ -177,14 +177,24 @@ def test_the_header_carries_the_fixed_copy_and_the_five_badges(): # The category noun, which the hero went without until 0.6: a reader had to reverse-engineer # what CTRLRun *is* from three slogans. The documentation root carried it; the README did not. assert "A Python library that sits between the decision to act and the call that acts." in head + # The row was cut from thirteen to ten on 2026-09-11: `pypi/pyversions` is metadata rather + # than a claim, and `ruff` and `mypy --strict` say how the library is written, which is not + # what a stranger is deciding on the first screen. `scripts/check.sh` still runs all three + # and `test_ci_runs_the_check_script` still requires CI to call it, so what the two badges + # asserted is enforced where it was always enforced. Their absence is required rather than + # merely untested: a row that grew back would otherwise pass. for badge in ( "pypi/v/ctrlrun", - "pypi/pyversions/ctrlrun", + "pypi/dm/ctrlrun", + "clones-badge.json", "ci.yml/badge.svg", + "fuzz.yml/badge.svg", "verify-badge.json", "pypi/l/ctrlrun", ): assert badge in head, badge + for gone in ("pypi/pyversions/ctrlrun", "astral-sh/ruff", "mypy-strict"): + assert gone not in head, gone marker = "generated from capabilities.yaml (readme)" assert marker not in head, "the capability matrix is not the first screen" assert marker in text, "the capability matrix was moved, not dropped" From 7e1cf518383299a0adf33b43d3d2fbf28abc22d0 Mon Sep 17 00:00:00 2001 From: arpan Date: Fri, 11 Sep 2026 21:05:03 +0530 Subject: [PATCH 2/2] Serialize the two publishers of the badges branch Raised in review of #137. `traffic.yml` and the `badge` job in `ci.yml` both push to the orphan `badges` branch and neither serialized nor retried. Two publishers that fetched the same head make two different commits, and the loser is rejected as a non-fast-forward -- which shows up as a badge that quietly stopped moving, the failure mode a badge is least able to report. Both jobs now share the concurrency group `badges-branch`, queued rather than cancelled, because a cancelled publish is the stale badge this prevents. `traffic.yml` also retries three times: re-fetch, re-apply this run's days onto whatever the newer head published, commit, push. The history is keyed by date and the API download is untracked, so the re-merge is the same assignment the job already does and cannot double-count. Exercised against a real rejection: a competing publisher at 150, this run holding 130, the retry lands 180 with all three days present and nothing lost from either side. --- .github/workflows/ci.yml | 6 +++ .github/workflows/traffic.yml | 78 ++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b62552..0b3cc24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,12 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Shared, verbatim, with the `clones` job in `traffic.yml`: the two publishers of the + # `badges` branch. Without it both can fetch the same head, and the second push is rejected + # as a non-fast-forward -- which shows up as a badge that quietly stopped moving. + concurrency: + group: badges-branch + cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/traffic.yml b/.github/workflows/traffic.yml index 26784f0..63e3d81 100644 --- a/.github/workflows/traffic.yml +++ b/.github/workflows/traffic.yml @@ -33,6 +33,15 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # The `badge` job in `ci.yml` writes the same branch. Two publishers that fetched the same + # head make two different commits, and the loser of the race is rejected as a + # non-fast-forward -- so the badge that lost keeps yesterday's number and nothing says so. + # The group name is shared with that job verbatim: a concurrency group is a string, and + # these two are the only writers of `badges`. Queued rather than cancelled, because a + # cancelled publish is the stale badge this is here to prevent. + concurrency: + group: badges-branch + cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -126,4 +135,71 @@ jobs: total=$(python -c 'import json;print(json.load(open("clones-badge.json"))["message"])') git add -f clones-badge.json clones-history.json git commit -m "clones: $total" || { echo "clones unchanged"; exit 0; } - git push origin badges + # Belt as well as braces. The concurrency group serializes the two publishers this + # repository has; a rejection can still arrive from a hand-run job or a future one, + # and re-reading the branch is cheap next to a badge that stops moving. The history + # is keyed by date, so re-applying this run's days onto a newer head is the same + # merge the job does anyway. + for attempt in 1 2 3; do + if git push origin badges; then + exit 0 + fi + echo "badges moved under us; re-reading (attempt $attempt)" + git fetch origin badges:refs/remotes/origin/badges + git reset --soft refs/remotes/origin/badges + git checkout refs/remotes/origin/badges -- . 2>/dev/null || true + python - <<'MERGE' + import json + from pathlib import Path + + # `clones-api.json` is still the untracked download, so the merge is re-run against + # whatever the newer head published rather than against what this job started with. + fresh = json.loads(Path("clones-api.json").read_text(encoding="utf-8")) + history_path = Path("clones-history.json") + history = ( + json.loads(history_path.read_text(encoding="utf-8")) + if history_path.exists() + else {"days": {}} + ) + days = history.get("days", {}) + for day in fresh.get("clones", []): + days[day["timestamp"][:10]] = { + "count": day["count"], + "uniques": day["uniques"], + } + days = dict(sorted(days.items())) + total = sum(day["count"] for day in days.values()) + uniques = sum(day["uniques"] for day in days.values()) + history_path.write_text( + json.dumps( + { + "since": next(iter(days), None), + "total": total, + "uniques": uniques, + "days": days, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + Path("clones-badge.json").write_text( + json.dumps( + { + "schemaVersion": 1, + "label": "clones", + "message": f"{total:,}", + "color": "B8730A", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + MERGE + total=$(python -c 'import json;print(json.load(open("clones-badge.json"))["message"])') + git add -f clones-badge.json clones-history.json + git commit -m "clones: $total" || true + done + echo "::warning::could not publish the clones badge after 3 attempts" + exit 0