Explorar el Código

ci(repo-stats): chart container pulls from ghcr.io alongside clones/stars

GHCR exposes total + 30-day daily-pull counts only in the package page
HTML (no REST or GraphQL endpoint). jgehrcke/github-repo-stats has no
notion of container metrics, so post-process the report after it runs.

New: .github/scripts/ghcr_inject.py
- Scrapes Total downloads (exact integer from title="N", not the K-rounded
  display) and the 30-day sparkline (rect data-merge-count, data-date).
- Merges per-day rows into maziggy/bambuddy/ghcr-pulls.csv on gh-pages.
  Fresh window overwrites overlapping dates, so GitHub's late revisions
  to the last 30 days self-correct; days older than 30 stay frozen at
  whatever was captured while still in-window.
- Patches latest-report/report.html: adds a TOC entry, a Container
  pulls (ghcr.io) section at the top, and a Vega-Lite line+point chart
  whose theme/config is cloned from the existing Total clones chart so
  it inherits the report's look-and-feel.
- Bracketed by HTML-comment markers so re-runs replace rather than
  stack (jgehrcke regenerates report.html every tick; we re-inject).
- Hard-fails if either scrape pattern stops matching — silent fallbacks
  would let the chart freeze without notice.

Workflow: after run-ghrs, checkout source + gh-pages, run the injector,
commit only if the diff is non-empty. Uses the existing contents: write
permission; no new secrets.
maziggy hace 2 meses
padre
commit
a5bb8a61a1
Se han modificado 2 ficheros con 363 adiciones y 0 borrados
  1. 326 0
      .github/scripts/ghcr_inject.py
  2. 37 0
      .github/workflows/repo-stats.yml

+ 326 - 0
.github/scripts/ghcr_inject.py

@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""Inject GHCR container-download stats into the jgehrcke/github-repo-stats report.
+
+GHCR exposes a container's total + 30-day daily-pull series only in the
+package page HTML. There is no REST or GraphQL API for it. This script
+scrapes that page once per workflow run, merges the rolling 30-day window
+into a sidecar CSV on gh-pages, and re-injects a Vega-Lite chart at the
+top of ``latest-report/report.html``.
+
+Why the merge: each run only sees the last 30 days, but the CSV grows
+forever — days that fall off GitHub's 30-day window stay in the CSV
+because they were captured while in-window. Overlapping dates are
+overwritten on each run, so GitHub's late-arriving revisions to recent
+days self-correct.
+
+Hard-fails if either scrape pattern stops matching. Silent fallbacks
+would let the chart freeze at last-known-good and nobody would notice.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+import re
+import sys
+import urllib.request
+from datetime import datetime, timezone
+from pathlib import Path
+
+GHCR_URL = "https://github.com/{owner}/{pkg}/pkgs/container/{pkg}"
+USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) bambuddy-stats"
+
+TOTAL_RE = re.compile(
+    r'Total downloads</span>\s*<h3 title="(\d+)">([^<]+)</h3>',
+    re.DOTALL,
+)
+RECT_MERGE_FIRST_RE = re.compile(r'data-merge-count="(\d+)"[^>]*data-date="(\d{4}-\d{2}-\d{2})"')
+RECT_DATE_FIRST_RE = re.compile(r'data-date="(\d{4}-\d{2}-\d{2})"[^>]*data-merge-count="(\d+)"')
+
+
+def fetch_ghcr(owner: str, pkg: str) -> str:
+    req = urllib.request.Request(
+        GHCR_URL.format(owner=owner, pkg=pkg),
+        headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
+    )
+    with urllib.request.urlopen(req, timeout=30) as resp:
+        return resp.read().decode("utf-8")
+
+
+def parse_total(html: str) -> tuple[int, str]:
+    m = TOTAL_RE.search(html)
+    if not m:
+        raise RuntimeError(
+            "GHCR scrape: 'Total downloads' marker not found. GitHub markup likely changed — update TOTAL_RE."
+        )
+    return int(m.group(1)), m.group(2).strip()
+
+
+def parse_daily(html: str) -> dict[str, int]:
+    daily: dict[str, int] = {}
+    for m in RECT_MERGE_FIRST_RE.finditer(html):
+        daily[m.group(2)] = int(m.group(1))
+    for m in RECT_DATE_FIRST_RE.finditer(html):
+        daily.setdefault(m.group(1), int(m.group(2)))
+    if not daily:
+        raise RuntimeError(
+            "GHCR scrape: 30-day sparkline rects not found. GitHub markup likely changed — update RECT_*_RE."
+        )
+    return daily
+
+
+def merge_csv(csv_path: Path, fresh: dict[str, int]) -> dict[str, int]:
+    merged: dict[str, int] = {}
+    if csv_path.exists():
+        with csv_path.open() as fp:
+            for row in csv.DictReader(fp):
+                merged[row["date"]] = int(row["daily_count"])
+    merged.update(fresh)
+    return dict(sorted(merged.items()))
+
+
+def write_csv(csv_path: Path, series: dict[str, int]) -> None:
+    csv_path.parent.mkdir(parents=True, exist_ok=True)
+    with csv_path.open("w", newline="") as fp:
+        w = csv.writer(fp)
+        w.writerow(["date", "daily_count"])
+        for date, count in series.items():
+            w.writerow([date, count])
+
+
+# Cloned verbatim from jgehrcke's "Total clones" chart so the new chart
+# inherits the report's theme (fonts, palette, axis colors).
+VEGA_CONFIG = {
+    "arc": {"fill": "#1b1e23"},
+    "area": {"fill": "#1b1e23"},
+    "axisBottom": {
+        "domainColor": "#a9b4c4",
+        "gridColor": "#a9b4c4",
+        "labelColor": "#1b1e23",
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "tickColor": "#a9b4c4",
+        "titleColor": "#1b1e23",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "axisLeft": {
+        "domainColor": "#a9b4c4",
+        "gridColor": "#a9b4c4",
+        "labelColor": "#1b1e23",
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "tickColor": "#a9b4c4",
+        "titleColor": "#1b1e23",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "axisX": {"grid": False},
+    "axisY": {"grid": False, "labelBound": True},
+    "background": "#FFFFFF",
+    "group": {"fill": "#FFFFFF"},
+    "header": {
+        "fontWeight": 400,
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "legend": {
+        "labelFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "symbolSize": 200,
+        "symbolType": "circle",
+        "titleFont": "relative-mono-11-pitch-pro, Menlo, monospace",
+    },
+    "line": {"color": "#1b1e23", "stroke": "#1b1e23"},
+    "path": {"stroke": "#1b1e23"},
+    "point": {
+        "color": "#1b1e23",
+        "cursor": "pointer",
+        "filled": True,
+        "size": 20,
+    },
+    "range": {
+        "category": ["#85a2f7", "#ea9755", "#7eb36a", "#f07071", "#bc85d9", "#e587b6", "#a9b4c4", "#d4c05e", "#64b9c4"],
+    },
+    "style": {
+        "bar": {"fill": "#1b1e23"},
+        "text": {
+            "font": "relative-mono-11-pitch-pro, Menlo, monospace",
+            "fontWeight": 400,
+        },
+    },
+    "symbol": {"shape": "circle"},
+    "title": {
+        "anchor": "start",
+        "font": "relative-mono-11-pitch-pro, Menlo, monospace",
+        "fontWeight": 400,
+    },
+    "trail": {"color": "#1b1e23", "stroke": "#1b1e23"},
+    "view": {"stroke": None},
+}
+
+
+def build_vega_spec(series: dict[str, int]) -> dict:
+    rows = [{"time": f"{date}T00:00:00+00:00", "daily_count": count} for date, count in series.items()]
+    counts = [r["daily_count"] for r in rows] or [1]
+    y_max = max(counts)
+    dates = sorted(series.keys())
+    x_domain = [dates[0], dates[-1]] if dates else None
+    return {
+        "$schema": "https://vega.github.io/schema/vega-lite/v4.17.0.json",
+        "config": VEGA_CONFIG,
+        "data": {"name": "data-ghcr-pulls"},
+        "datasets": {"data-ghcr-pulls": rows},
+        "encoding": {
+            "tooltip": [
+                {"field": "daily_count", "format": ".0f", "title": "pulls", "type": "quantitative"},
+                {"field": "time", "format": "%B %e, %Y", "title": "date", "type": "temporal"},
+            ],
+            "x": {
+                "axis": {"labelAngle": 25},
+                "field": "time",
+                "scale": {"domain": x_domain} if x_domain else {},
+                "timeUnit": "yearmonthdate",
+                "title": "date",
+                "type": "temporal",
+            },
+            "y": {
+                "axis": {"values": [1, 10, 50, 100, 500, 1000, 5000, 10000, 50000]},
+                "field": "daily_count",
+                "scale": {
+                    "domain": [0, y_max * 1.1 if y_max > 0 else 1],
+                    "type": "symlog",
+                    "zero": True,
+                },
+                "title": "container pulls per day",
+                "type": "quantitative",
+            },
+        },
+        "height": 200,
+        "mark": {"point": True, "type": "line"},
+        "padding": 10,
+        "width": "container",
+    }
+
+
+TOC_START = "<!-- ghcr:toc-start -->"
+TOC_END = "<!-- ghcr:toc-end -->"
+SECTION_START = "<!-- ghcr:section-start -->"
+SECTION_END = "<!-- ghcr:section-end -->"
+SCRIPT_START = "<!-- ghcr:script-start -->"
+SCRIPT_END = "<!-- ghcr:script-end -->"
+
+
+def _strip_existing(html: str, start: str, end: str) -> str:
+    pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
+    return pattern.sub("", html)
+
+
+def patch_report(
+    report_path: Path,
+    spec: dict,
+    cumulative: int,
+    cumulative_display: str,
+    fetched_at: str,
+    owner: str,
+    pkg: str,
+) -> None:
+    html = report_path.read_text(encoding="utf-8")
+
+    # Idempotency: if a prior run left markers (shouldn't happen because
+    # jgehrcke regenerates the file, but guard against partial re-runs),
+    # strip them before re-injecting.
+    for s, e in (
+        (TOC_START, TOC_END),
+        (SECTION_START, SECTION_END),
+        (SCRIPT_START, SCRIPT_END),
+    ):
+        html = _strip_existing(html, s, e)
+
+    toc_block = f'{TOC_START}\n<li><a href="#ghcr-pulls">Container pulls (ghcr.io)</a></li>\n{TOC_END}\n'
+    section_block = (
+        f"{SECTION_START}\n"
+        f'<h2 id="ghcr-pulls">Container pulls (ghcr.io)</h2>\n'
+        f"<p>Daily pulls of <code>ghcr.io/{owner}/{pkg}</code>. "
+        f"Cumulative: <strong>{cumulative:,}</strong> "
+        f"({cumulative_display}). Source refreshed {fetched_at}.</p>\n"
+        f'<h4 id="ghcr-pulls-daily">Pulls per day</h4>\n'
+        f'<div id="chart_ghcr_pulls_daily" class="full-width-chart">\n\n</div>\n'
+        f'<div class="pagebreak-for-print">\n\n</div>\n'
+        f"{SECTION_END}\n"
+    )
+    script_block = (
+        f"{SCRIPT_START}\n"
+        f'<script type="text/javascript">\n'
+        f"vegaEmbed('#chart_ghcr_pulls_daily', "
+        f"{json.dumps(spec, separators=(',', ':'))}, "
+        f'{{"actions": false, "renderer": "svg"}}).catch(console.error);\n'
+        f"</script>\n"
+        f"{SCRIPT_END}\n"
+    )
+
+    toc_anchor = "<p>Table of contents:</p>\n<ul>\n"
+    if toc_anchor not in html:
+        raise RuntimeError("report.html: TOC anchor not found; layout drift?")
+    html = html.replace(toc_anchor, toc_anchor + toc_block, 1)
+
+    section_anchor = "</nav>\n"
+    if section_anchor not in html:
+        raise RuntimeError("report.html: section anchor (</nav>) not found; layout drift?")
+    html = html.replace(section_anchor, section_anchor + section_block, 1)
+
+    script_anchor = "</article>\n"
+    if script_anchor not in html:
+        raise RuntimeError("report.html: script anchor (</article>) not found; layout drift?")
+    html = html.replace(script_anchor, script_block + script_anchor, 1)
+
+    report_path.write_text(html, encoding="utf-8")
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--report", required=True, type=Path)
+    parser.add_argument("--csv", required=True, type=Path)
+    parser.add_argument("--owner", required=True)
+    parser.add_argument("--pkg", required=True)
+    parser.add_argument(
+        "--ghcr-cache",
+        type=Path,
+        default=None,
+        help="Read GHCR HTML from a local file instead of fetching. For local dry-runs only.",
+    )
+    args = parser.parse_args()
+
+    if not args.report.exists():
+        print(f"::error::report not found: {args.report}", file=sys.stderr)
+        return 1
+
+    if args.ghcr_cache:
+        html = args.ghcr_cache.read_text(encoding="utf-8")
+        print(f"Loaded cached GHCR HTML: {args.ghcr_cache} ({len(html):,} bytes)")
+    else:
+        html = fetch_ghcr(args.owner, args.pkg)
+        print(f"Fetched GHCR page: {len(html):,} bytes")
+
+    cumulative, cumulative_display = parse_total(html)
+    fresh_daily = parse_daily(html)
+    print(f"Cumulative pulls: {cumulative:,} ({cumulative_display})")
+    print(f"Fresh days from sparkline: {len(fresh_daily)}")
+
+    merged = merge_csv(args.csv, fresh_daily)
+    write_csv(args.csv, merged)
+    print(f"Merged CSV rows: {len(merged)} -> {args.csv}")
+
+    spec = build_vega_spec(merged)
+    fetched_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+    patch_report(
+        args.report,
+        spec,
+        cumulative,
+        cumulative_display,
+        fetched_at,
+        args.owner,
+        args.pkg,
+    )
+    print(f"Patched: {args.report}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 37 - 0
.github/workflows/repo-stats.yml

@@ -19,3 +19,40 @@ jobs:
           repository: maziggy/bambuddy
           repository: maziggy/bambuddy
           ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
           ghtoken: ${{ secrets.GHRS_GITHUB_API_TOKEN }}
           ghpagesprefix: https://maziggy.github.io/bambuddy
           ghpagesprefix: https://maziggy.github.io/bambuddy
+
+      # Inject ghcr.io container-download stats (cumulative + 30-day
+      # sparkline merged into a sidecar CSV so the chart grows beyond
+      # the 30-day window GHCR exposes). Runs after jgehrcke regenerates
+      # report.html on gh-pages.
+      - name: checkout-source
+        uses: actions/checkout@v4
+        with:
+          path: source
+
+      - name: checkout-gh-pages
+        uses: actions/checkout@v4
+        with:
+          ref: gh-pages
+          path: gh-pages
+
+      - name: inject-ghcr-pulls
+        run: |
+          python3 source/.github/scripts/ghcr_inject.py \
+            --report gh-pages/maziggy/bambuddy/latest-report/report.html \
+            --csv    gh-pages/maziggy/bambuddy/ghcr-pulls.csv \
+            --owner  maziggy \
+            --pkg    bambuddy
+
+      - name: commit-and-push
+        working-directory: gh-pages
+        run: |
+          if [ -z "$(git status --porcelain)" ]; then
+            echo "no changes — skipping commit"
+            exit 0
+          fi
+          git config user.name  "github-actions[bot]"
+          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+          git add maziggy/bambuddy/latest-report/report.html \
+                  maziggy/bambuddy/ghcr-pulls.csv
+          git commit -m "ghcr-pulls: refresh container downloads chart"
+          git push