Skip to content

Release download stats #462

Release download stats

Release download stats #462

Workflow file for this run

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."