diff --git a/.github/scripts/accumulate_clones.py b/.github/scripts/accumulate_clones.py new file mode 100644 index 0000000..a71ce15 --- /dev/null +++ b/.github/scripts/accumulate_clones.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# Copyright 2026 EEP Contributors — Apache-2.0 +"""Accumulate GitHub clone counts into a running total. + +GitHub's traffic API (`GET /repos/{owner}/{repo}/traffic/clones`) only retains +the last 14 days, so a daily job must merge each fresh window into a persisted +total. This script reads: + + - ``clone.json`` — the current 14-day API response (today's window) + - ``clone_before.json`` — the running total persisted in a Gist + +and writes the merged running total back to ``clone.json`` (which the workflow +then PATCHes into the Gist; the README badge reads ``count`` from it). + +Vendored from MShawon/github-clone-count-badge (MIT) rather than curl-piped +into ``python3`` at runtime, so no unpinned remote code executes in CI — in +keeping with this repo's supply-chain posture. Behaviour is unchanged except +for tolerating a missing ``clones`` array on the very first run. + +Note: the total only accumulates from the day this job starts running; clones +from before setup are not exposed by GitHub and cannot be recovered. +""" + +import json + + +def _load(path: str) -> dict: + with open(path, "r", encoding="utf-8") as fh: + return json.load(fh) + + +def main() -> None: + now = _load("clone.json") + before = _load("clone_before.json") + + before_clones = before.get("clones", []) or [] + now_clones = now.get("clones", []) or [] + + # Index the persisted per-day entries by timestamp so a fresh window + # overwrites overlapping days (same 14-day rows) and appends new ones. + timestamps = {entry["timestamp"]: i for i, entry in enumerate(before_clones)} + + latest = dict(before) + latest["clones"] = before_clones + for entry in now_clones: + ts = entry["timestamp"] + if ts in timestamps: + latest["clones"][timestamps[ts]] = entry + else: + latest["clones"].append(entry) + + latest["count"] = sum(int(c["count"]) for c in latest["clones"]) + latest["uniques"] = sum(int(c["uniques"]) for c in latest["clones"]) + + # Compaction: once history grows past 100 daily rows, fold the oldest + # (keeping the most recent 35 days at daily granularity) into monthly + # buckets so the Gist payload stays small. + if len(latest["clones"]) > 100: + clones = latest["clones"] + remove_this = [] + for i in range(len(clones) - 35): + clones[i]["timestamp"] = clones[i]["timestamp"][:7] + if clones[i]["timestamp"] == clones[i + 1]["timestamp"][:7]: + clones[i + 1]["count"] += clones[i]["count"] + clones[i + 1]["uniques"] += clones[i]["uniques"] + remove_this.append(clones[i]) + for item in remove_this: + clones.remove(item) + + with open("clone.json", "w", encoding="utf-8") as fh: + json.dump(latest, fh, ensure_ascii=False, indent=4) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/clone-count.yml b/.github/workflows/clone-count.yml new file mode 100644 index 0000000..7212593 --- /dev/null +++ b/.github/workflows/clone-count.yml @@ -0,0 +1,85 @@ +name: Clone count badge + +# Maintains the total git-clone count shown by the badge in README.md. +# +# GitHub's traffic API only retains the last 14 days of clone data, so this job +# runs daily, fetches the current window, and merges it into a running total +# stored in a public Gist. shields.io reads that Gist to render the badge. +# +# Hardened vs. the upstream recipe (MShawon/github-clone-count-badge): the +# accumulation script is vendored (.github/scripts/accumulate_clones.py) instead +# of curl-piped into python3, the action is pinned, permissions are read-only, +# and nothing is committed back to the repo. +# +# ── One-time setup (see docs/ops/clone-count-badge.md) ─────────────────────── +# 1. Create a PUBLIC gist with a single file `clone.json` containing: +# {"count": 0, "uniques": 0, "clones": []} +# Note its id (the hash in the gist URL). +# 2. Create a classic PAT with the `repo` and `gist` scopes and add it as the +# `SECRET_TOKEN` Actions secret (Settings → Secrets and variables → Actions). +# 3. Add a repository variable `GIST_ID` = the gist id from step 1 +# (Settings → Secrets and variables → Actions → Variables). +# 4. Replace `__GIST_ID__` in the README badge URL with the same gist id. +# The total accumulates from the first run onward (historical clones are not +# exposed by GitHub). + +on: + schedule: + - cron: "0 3 * * *" # daily, 03:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + clone-count: + runs-on: ubuntu-latest + env: + GIST_USER: ucekmez + GIST_ID: ${{ vars.GIST_ID }} + steps: + - uses: actions/checkout@v6 # renovate: pin + + - name: Guard — GIST_ID configured + run: | + if [ -z "${GIST_ID}" ]; then + echo "::error::Repository variable GIST_ID is not set. See docs/ops/clone-count-badge.md" + exit 1 + fi + + - name: Fetch current 14-day clone window + env: + TOKEN: ${{ secrets.SECRET_TOKEN }} + run: | + curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${TOKEN}" \ + "https://api.github.com/repos/${GITHUB_REPOSITORY}/traffic/clones" \ + -o clone.json + + - name: Download running total from the gist + run: | + if ! curl -fsSL "https://gist.githubusercontent.com/${GIST_USER}/${GIST_ID}/raw/clone.json" -o clone_before.json; then + echo "Gist not readable yet — seeding an empty total." + echo '{"count": 0, "uniques": 0, "clones": []}' > clone_before.json + fi + + - name: Accumulate into the running total + run: python3 .github/scripts/accumulate_clones.py + + - name: Update the gist + env: + TOKEN: ${{ secrets.SECRET_TOKEN }} + run: | + # Embed the file contents as a JSON string (python handles escaping). + python3 - <<'PY' > payload.json + import json + body = open("clone.json", encoding="utf-8").read() + print(json.dumps({"files": {"clone.json": {"content": body}}})) + PY + curl -fsSL -X PATCH \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${TOKEN}" \ + "https://api.github.com/gists/${GIST_ID}" \ + -d @payload.json -o /dev/null + echo "Updated gist ${GIST_ID} — total clones: $(python3 -c "import json;print(json.load(open('clone.json'))['count'])")" diff --git a/README.md b/README.md index 87f82d9..bc22d8f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ [](./LICENSE) [](./CODE_OF_CONDUCT.md) [](./docs/current/SPECIFICATION.md) +[](./docs/ops/clone-count-badge.md)
diff --git a/docs/ops/clone-count-badge.md b/docs/ops/clone-count-badge.md
new file mode 100644
index 0000000..1cba573
--- /dev/null
+++ b/docs/ops/clone-count-badge.md
@@ -0,0 +1,72 @@
+# Clone-count badge
+
+The README shows a **total git-clone count** badge. GitHub's traffic API only
+retains the last **14 days** of clone data, so a scheduled job
+([`.github/workflows/clone-count.yml`](../../.github/workflows/clone-count.yml))
+runs daily, fetches the current window, and merges it into a running total kept
+in a public Gist. [shields.io](https://shields.io) renders the badge from that
+Gist.
+
+> **What "total" means.** The counter accumulates **from the first run onward**.
+> Clones from before you set this up are not exposed by GitHub and cannot be
+> back-filled. The number therefore grows over time; it is *not* an all-time
+> figure since repository creation.
+
+This is adapted from
+[MShawon/github-clone-count-badge](https://github.com/MShawon/github-clone-count-badge),
+hardened for this repo: the accumulation script is **vendored**
+([`.github/scripts/accumulate_clones.py`](../../.github/scripts/accumulate_clones.py))
+rather than `curl`-piped into `python3` at runtime, the workflow runs with
+read-only permissions, the action is pinned, and nothing is committed back to
+the repository.
+
+## One-time setup
+
+1. **Create a public Gist.** At