Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
205 changes: 205 additions & 0 deletions .github/workflows/traffic.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@

<!-- generated from tools/docs_audit/render_badges.py (readme) — edit the list, not this -->
<p align="center">
<a href="https://github.com/CTRLRun/ctrlrun/blob/badges/clones-history.json"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/CTRLRun/ctrlrun/badges/clones-badge.json" alt="Clones"></a>
<a href="https://pypi.org/project/ctrlrun/"><img src="https://img.shields.io/pypi/v/ctrlrun?color=B8730A&label=pypi" alt="PyPI"></a>
<a href="https://pypi.org/project/ctrlrun/"><img src="https://img.shields.io/pypi/pyversions/ctrlrun?color=B8730A" alt="Python versions"></a>
<a href="https://pypistats.org/packages/ctrlrun"><img src="https://img.shields.io/pypi/dm/ctrlrun?color=B8730A&label=downloads" alt="Downloads"></a>
<a href="https://ctrlrun.dev"><img src="https://img.shields.io/badge/docs-ctrlrun.dev-B8730A" alt="Docs"></a>
<a href="https://github.com/CTRLRun/ctrlrun/actions/workflows/ci.yml"><img src="https://github.com/CTRLRun/ctrlrun/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/CTRLRun/ctrlrun/actions/workflows/codeql.yml"><img src="https://github.com/CTRLRun/ctrlrun/actions/workflows/codeql.yml/badge.svg?branch=main" alt="CodeQL"></a>
<a href="https://github.com/CTRLRun/ctrlrun/actions/workflows/fuzz.yml"><img src="https://github.com/CTRLRun/ctrlrun/actions/workflows/fuzz.yml/badge.svg?branch=main" alt="Fuzz"></a>
<a href="https://ctrlrun.dev/docs/how-this-is-built"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/CTRLRun/ctrlrun/badges/tests-badge.json" alt="Tests"></a>
<a href="https://ctrlrun.dev/docs/security/verify-guarantees"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/CTRLRun/ctrlrun/badges/verify-badge.json" alt="CTRLRun verified"></a>
<a href="https://scorecard.dev/viewer/?uri=github.com/CTRLRun/ctrlrun"><img src="https://api.scorecard.dev/projects/github.com/CTRLRun/ctrlrun/badge" alt="OpenSSF Scorecard"></a>
<a href="https://github.com/astral-sh/ruff"><img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff"></a>
<a href="https://github.com/CTRLRun/ctrlrun/blob/main/scripts/check.sh"><img src="https://img.shields.io/badge/mypy-strict-B8730A" alt="Checked with mypy --strict"></a>
<a href="https://github.com/CTRLRun/ctrlrun/blob/main/LICENSE"><img src="https://img.shields.io/pypi/l/ctrlrun?color=B8730A" alt="License"></a>
</p>
<!-- end generated -->
Expand Down
12 changes: 11 additions & 1 deletion tests/test_readme_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down