Repository navigation
Release download stats #462
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release download stats | |
| # Builds stats.json / CSVs on gh-pages for a Widgy (or similar) widget. | |
| # Counts GitHub Release asset downloads for NumWorks-*.zip (and legacy NumWorks.zip). | |
| # Deploys to gh-pages only when download data actually changed (not on every cron). | |
| # | |
| # Widgy JSON URL: | |
| # https://ellandeved.github.io/NumworksApplication/stats.json | |
| on: | |
| schedule: | |
| - cron: "0 2,14 * * *" # 02:00 and 14:00 UTC | |
| workflow_dispatch: | |
| permissions: | |
| contents: write | |
| jobs: | |
| build: | |
| runs-on: ubuntu-latest | |
| env: | |
| OWNER: EllandeVED | |
| REPO: NumworksApplication | |
| steps: | |
| - name: Fetch existing stats from gh-pages | |
| run: | | |
| mkdir -p site | |
| curl -sfL "https://${OWNER}.github.io/${REPO}/history.json" -o site/history.prev.json \ | |
| || echo '{"releases":{}}' > site/history.prev.json | |
| cp site/history.prev.json site/history.json | |
| curl -sfL "https://${OWNER}.github.io/${REPO}/stats.json" -o site/stats.prev.json \ | |
| || echo '{}' > site/stats.prev.json | |
| - name: Build stats.json for Widgy | |
| id: stats | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| python3 - <<'PY' | |
| import json, os, urllib.request, datetime, pathlib, re | |
| owner = os.environ["OWNER"] | |
| repo = os.environ["REPO"] | |
| token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") or "" | |
| github_output = os.environ.get("GITHUB_OUTPUT") | |
| api = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=20" | |
| headers = { | |
| "Accept": "application/vnd.github+json", | |
| "User-Agent": f"{owner}-{repo}-release-stats", | |
| } | |
| if token: | |
| headers["Authorization"] = f"Bearer {token}" | |
| req = urllib.request.Request(api, headers=headers) | |
| releases = json.loads(urllib.request.urlopen(req).read().decode("utf-8")) | |
| releases = [r for r in releases if not r.get("draft")] | |
| releases.sort(key=lambda r: r.get("published_at") or "", reverse=True) | |
| releases = releases[:2] | |
| def ddmmyy(iso_dt: str) -> str: | |
| if not iso_dt: | |
| return "" | |
| d = iso_dt.split("T")[0] | |
| y, m, dd = d.split("-") | |
| return f"{dd}/{m}/{y[2:]}" | |
| def asset_downloads(release) -> int: | |
| tag = release.get("tag_name") or "" | |
| assets = release.get("assets") or [] | |
| preferred = f"NumWorks-{tag}.zip" | |
| for a in assets: | |
| if a.get("name") == preferred: | |
| return int(a.get("download_count", 0)) | |
| total = 0 | |
| matched = False | |
| for a in assets: | |
| name = a.get("name") or "" | |
| if name == "NumWorks.zip" or re.fullmatch(r"NumWorks-.*\.zip", name): | |
| total += int(a.get("download_count", 0)) | |
| matched = True | |
| return total if matched else 0 | |
| def meaningful(payload: dict, hist: dict) -> dict: | |
| """Fingerprint without the always-changing 'updated' field.""" | |
| return { | |
| "asset": payload.get("asset"), | |
| "days": payload.get("days"), | |
| "latest2": payload.get("latest2"), | |
| "downloads_total_latest": payload.get("downloads_total_latest"), | |
| "history": hist, | |
| } | |
| today = datetime.date.today() | |
| days = [(today - datetime.timedelta(days=i)).isoformat() for i in range(6, -1, -1)] | |
| today_key = days[-1] | |
| out_dir = pathlib.Path("site") | |
| prev_hist = json.loads((out_dir / "history.prev.json").read_text(encoding="utf-8")) | |
| hist = json.loads(json.dumps(prev_hist)) # deep copy | |
| for rel in hist.get("releases", {}).values(): | |
| totals = rel.get("totals", {}) | |
| if isinstance(totals, dict): | |
| rel["totals"] = {str(k): v for k, v in totals.items()} | |
| latest2 = [] | |
| for r in releases: | |
| tag = r.get("tag_name", "") | |
| total = asset_downloads(r) | |
| rel = hist["releases"].setdefault(tag, {"totals": {}}) | |
| rel["published_date"] = ddmmyy(r.get("published_at", "")) | |
| totals = rel.get("totals", {}) | |
| totals[today_key] = total | |
| totals = {d: totals.get(d) for d in days if d in totals} | |
| rel["totals"] = totals | |
| vals = [] | |
| prev = None | |
| for d in days: | |
| cur = totals.get(d) | |
| if cur is None: | |
| vals.append(0) | |
| continue | |
| if prev is None: | |
| vals.append(0) | |
| else: | |
| vals.append(max(0, cur - prev)) | |
| prev = cur | |
| latest2.append({ | |
| "tag": tag, | |
| "date": rel["published_date"], | |
| "downloads_total": total, | |
| "downloads_per_day_last7": vals, | |
| }) | |
| updated = datetime.datetime.now(datetime.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") | |
| payload = { | |
| "updated": updated, | |
| "asset": "NumWorks-*.zip", | |
| "days": days, | |
| "latest2": latest2, | |
| "downloads_total_latest": latest2[0]["downloads_total"] if latest2 else 0, | |
| } | |
| prev_payload = json.loads((out_dir / "stats.prev.json").read_text(encoding="utf-8")) | |
| changed = meaningful(payload, hist) != meaningful(prev_payload, prev_hist) | |
| def set_output(key: str, value: str) -> None: | |
| if not github_output: | |
| return | |
| with open(github_output, "a", encoding="utf-8") as fh: | |
| fh.write(f"{key}={value}\n") | |
| if not changed: | |
| print("No meaningful stats change — skip deploy") | |
| set_output("changed", "false") | |
| raise SystemExit(0) | |
| def day2(iso_day: str) -> str: | |
| return datetime.date.fromisoformat(iso_day).strftime("%a")[:2] | |
| def write_csv(filename: str, release_obj: dict) -> None: | |
| lines = ["day,value"] | |
| for iso_day, val in zip(days, release_obj["downloads_per_day_last7"]): | |
| lines.append(f"{day2(iso_day)},{int(val)}") | |
| (out_dir / filename).write_text("\n".join(lines) + "\n", encoding="utf-8") | |
| if len(latest2) > 0: | |
| write_csv("stats_r0.csv", latest2[0]) | |
| if len(latest2) > 1: | |
| write_csv("stats_r1.csv", latest2[1]) | |
| (out_dir / "history.json").write_text(json.dumps(hist, indent=2) + "\n", encoding="utf-8") | |
| (out_dir / "stats.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") | |
| set_output("changed", "true") | |
| print(json.dumps(payload, indent=2)) | |
| PY | |
| # IMPORTANT: only update stats files — never wipe Sparkle zips / appcast. | |
| - name: Publish stats to gh-pages | |
| if: steps.stats.outputs.changed == 'true' | |
| run: | | |
| git clone --branch gh-pages --single-branch \ | |
| "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${OWNER}/${REPO}.git" pages | |
| cp site/stats.json site/history.json pages/ | |
| cp site/stats_r0.csv pages/ 2>/dev/null || true | |
| cp site/stats_r1.csv pages/ 2>/dev/null || true | |
| mkdir -p pages/site | |
| cp site/stats.json site/history.json pages/site/ | |
| cp site/stats_r0.csv pages/site/ 2>/dev/null || true | |
| cp site/stats_r1.csv pages/site/ 2>/dev/null || true | |
| touch pages/.nojekyll | |
| cd pages | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git add stats.json history.json stats_r0.csv stats_r1.csv site .nojekyll | |
| if git diff --cached --quiet; then | |
| echo "No file-level diff after copy — skip push" | |
| else | |
| git commit -m "Update download stats" | |
| git push origin gh-pages | |
| fi | |
| - name: Skip deploy notice | |
| if: steps.stats.outputs.changed != 'true' | |
| run: echo "Stats unchanged — no gh-pages deploy." |