From 81cdaa71b2a798e3c27a2d302cd5ba497c64b29e Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Sat, 27 Jun 2026 21:01:04 +0300 Subject: [PATCH] feat(ci): add total clone-count badge to the README Adds a daily job that accumulates git-clone counts into a public Gist and a shields.io badge in the README. GitHub's traffic API only retains 14 days of clone data, so the workflow merges each window into a running total. Adapted from MShawon/github-clone-count-badge, hardened for this repo: - the accumulation script is vendored (.github/scripts/accumulate_clones.py) rather than curl-piped into python3 at runtime (no unpinned remote code executes in CI); - the workflow runs with `permissions: contents: read`, pins the action, and commits nothing back to the repo (no CLONE.md / third-party push action); - the gist id is a repository variable (it is public) and the PAT lives in the `SECRET_TOKEN` secret, used only for the traffic + gist API calls. The total accumulates from the first run onward; historical clones are not exposed by GitHub. One-time setup and rotation are documented in docs/ops/clone-count-badge.md. The README badge URL carries a `__GIST_ID__` placeholder to be replaced with the gist id during setup. Signed-off-by: Ugur Cekmez Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/accumulate_clones.py | 75 ++++++++++++++++++++++++ .github/workflows/clone-count.yml | 85 ++++++++++++++++++++++++++++ README.md | 1 + docs/ops/clone-count-badge.md | 72 +++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 .github/scripts/accumulate_clones.py create mode 100644 .github/workflows/clone-count.yml create mode 100644 docs/ops/clone-count-badge.md 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](https://img.shields.io/badge/License-Apache%202.0-green)](./LICENSE) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](./CODE_OF_CONDUCT.md) [![EEP compatible](./assets/badges/eep-compatible.svg)](./docs/current/SPECIFICATION.md) +[![GitHub Clones](https://img.shields.io/badge/dynamic/json?color=success&label=clones&query=count&url=https://gist.githubusercontent.com/ucekmez/__GIST_ID__/raw/clone.json&logo=github)](./docs/ops/clone-count-badge.md)

Two terminal panes running in parallel: an agent fetching the same quarterly report via current-web HTML scraping (~26s, ~46 KB, ~11.5K tokens, 2 simulated human steps) vs EEP (~10s, ~2.2 KB, ~386 tokens, 0 human steps). Deterministic, no LLM calls. 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 create a *public* + gist with a single file named `clone.json`: + + ```json + { "count": 0, "uniques": 0, "clones": [] } + ``` + + Copy its id — the hash in the URL + `https://gist.github.com//`. + +2. **Create a token.** Generate a *classic* Personal Access Token with the + **`repo`** scope (the traffic API requires push access) and the **`gist`** + scope (to update the gist). Add it under + **Settings → Secrets and variables → Actions → Secrets** as `SECRET_TOKEN`. + +3. **Add the gist id as a variable.** Under + **Settings → Secrets and variables → Actions → Variables**, add + `GIST_ID` = the id from step 1. (The id is public — it appears in the badge + URL — so it is a *variable*, not a secret.) + +4. **Point the badge at the gist.** In [`README.md`](../../README.md) replace + `__GIST_ID__` in the clone badge URL with the same id. + +5. **Bootstrap.** Trigger the workflow once via + **Actions → "Clone count badge" → Run workflow**. After it succeeds the gist + holds the first window and the badge renders. + +If the gist owner is not `ucekmez`, also update `GIST_USER` in the workflow and +the username in the README badge URL. + +## How it works + +| Step | What happens | +|------|--------------| +| Fetch | `GET /repos/{owner}/{repo}/traffic/clones` → `clone.json` (last 14 days) | +| Load total | Download `clone.json` from the gist → `clone_before.json` | +| Merge | `accumulate_clones.py` overwrites overlapping days and appends new ones, then re-sums `count` / `uniques` | +| Persist | `PATCH /gists/{id}` writes the merged total back | +| Render | shields.io reads `count` from the gist's raw `clone.json` | + +Older history is compacted into monthly buckets once it exceeds ~100 daily rows, +keeping the gist payload small. + +## Rotating the token + +`SECRET_TOKEN` is a classic PAT and will expire. When clone numbers stop +increasing, regenerate the PAT (same `repo` + `gist` scopes) and update the +`SECRET_TOKEN` secret. No code change is needed.