Skip to content
Open
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
75 changes: 75 additions & 0 deletions .github/scripts/accumulate_clones.py
Original file line number Diff line number Diff line change
@@ -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()
85 changes: 85 additions & 0 deletions .github/workflows/clone-count.yml
Original file line number Diff line number Diff line change
@@ -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'])")"
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

<p align="center">
<img src="./assets/realworld-demo.gif" alt="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." width="1000"/>
Expand Down
72 changes: 72 additions & 0 deletions docs/ops/clone-count-badge.md
Original file line number Diff line number Diff line change
@@ -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 <https://gist.github.com> 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/<user>/<THIS_IS_THE_ID>`.

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.