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 new file mode 100644 index 0000000..63e3d81 --- /dev/null +++ b/.github/workflows/traffic.yml @@ -0,0 +1,205 @@ +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 + # 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 + + - 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; } + # 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 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"