diff --git a/.github/workflows/registry-metrics.yml b/.github/workflows/registry-metrics.yml new file mode 100644 index 0000000..a759db1 --- /dev/null +++ b/.github/workflows/registry-metrics.yml @@ -0,0 +1,69 @@ +name: Registry Metrics Snapshot + +# Daily snapshot of this node's public ComfyUI Registry metrics +# (https://api.comfy.org/nodes/imagemetahub-comfyui-save), appended +# append-only to metrics/registry-history.jsonl and committed back to main. +# +# No external service, no paid dependency, no secret required. +on: + schedule: + # 06:17 UTC daily. Off-the-hour minute to avoid the top-of-hour scheduler crunch. + - cron: "17 6 * * *" + workflow_dispatch: {} + +# Minimal token permissions: only what's needed to commit the snapshot back. +permissions: + contents: write + +# Avoid overlapping snapshot commits if a manual run and the cron overlap. +concurrency: + group: registry-metrics-snapshot + cancel-in-progress: false + +jobs: + snapshot: + name: Fetch and commit registry snapshot + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'LuqP2' }} + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Fetch registry metrics and append snapshot + run: python scripts/registry_metrics_snapshot.py + + - name: Commit snapshot + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git diff --quiet -- metrics/registry-history.jsonl; then + echo "No new snapshot data to commit." + exit 0 + fi + + git add metrics/registry-history.jsonl + # [skip ci] prevents this bot commit from triggering other push-based + # workflows (e.g. the registry publish workflow) in this repo. + git commit -m "chore(metrics): registry snapshot $(date -u +%Y-%m-%d) [skip ci]" + + # Guard against a rare race with another push to main landing + # between checkout and push. + for attempt in 1 2 3; do + if git push origin HEAD:main; then + exit 0 + fi + echo "Push rejected (attempt $attempt), rebasing onto latest main..." + git fetch origin main + git rebase origin/main + done + echo "::error::Failed to push registry snapshot after 3 attempts." + exit 1 diff --git a/scripts/registry_metrics_snapshot.py b/scripts/registry_metrics_snapshot.py new file mode 100644 index 0000000..4b9ada4 --- /dev/null +++ b/scripts/registry_metrics_snapshot.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +Registry Metrics Snapshot + +Fetches the current public metrics for this node from the ComfyUI Registry +API and appends one JSON-Lines record to metrics/registry-history.jsonl. + +- Append-only: existing lines are never rewritten. +- Stdlib only (urllib), no extra dependencies, no secrets required. +- Safe to run locally: `python scripts/registry_metrics_snapshot.py` + +Exit codes: + 0 success (record appended, or nothing to do) + 1 the registry request failed / returned unexpected data +""" +from __future__ import annotations + +import json +import os +import sys +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +NODE_ID = "imagemetahub-comfyui-save" +REGISTRY_URL = f"https://api.comfy.org/nodes/{NODE_ID}" +HISTORY_FILE = Path(__file__).resolve().parent.parent / "metrics" / "registry-history.jsonl" +REQUEST_TIMEOUT = 30 + + +def fetch_registry_data(url: str) -> dict: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: + body = response.read() + except (urllib.error.URLError, TimeoutError) as error: + raise RuntimeError(f"Failed to reach {url}: {error}") from error + + try: + data = json.loads(body) + except json.JSONDecodeError as error: + raise RuntimeError(f"Registry response was not valid JSON: {error}") from error + + if not isinstance(data, dict) or "id" not in data: + raise RuntimeError(f"Unexpected registry payload shape: {data!r}") + + return data + + +def read_last_record(path: Path) -> dict | None: + if not path.exists(): + return None + last_line = None + with path.open("r", encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if line: + last_line = line + if last_line is None: + return None + try: + return json.loads(last_line) + except json.JSONDecodeError: + return None + + +def compute_delta(current: dict, previous: dict | None) -> dict | None: + if previous is None: + return None + + def numeric_delta(key: str): + cur_val = current.get(key) + prev_val = previous.get(key) + if not isinstance(cur_val, (int, float)) or not isinstance(prev_val, (int, float)): + return None + delta = cur_val - prev_val + return round(delta, 4) if isinstance(delta, float) else delta + + return { + "downloads": numeric_delta("downloads"), + "github_stars": numeric_delta("github_stars"), + "rating": numeric_delta("rating"), + "search_ranking": numeric_delta("search_ranking"), + "version_changed": current.get("latest_version") != previous.get("latest_version"), + "status_changed": current.get("status") != previous.get("status"), + } + + +def build_record(payload: dict, previous: dict | None) -> dict: + latest_version = payload.get("latest_version") or {} + record = { + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "node_id": payload.get("id", NODE_ID), + "downloads": payload.get("downloads"), + "github_stars": payload.get("github_stars"), + "rating": payload.get("rating"), + "search_ranking": payload.get("search_ranking"), + "latest_version": latest_version.get("version"), + "status": payload.get("status"), + } + record["delta"] = compute_delta(record, previous) + return record + + +def append_record(path: Path, record: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8", newline="\n") as handle: + handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True)) + handle.write("\n") + + +def write_step_summary(record: dict) -> None: + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + delta = record.get("delta") or {} + lines = [ + "### Registry metrics snapshot", + "", + f"- timestamp: `{record['timestamp']}`", + f"- downloads: **{record['downloads']}** (Δ {delta.get('downloads', 'n/a')})", + f"- github_stars: **{record['github_stars']}** (Δ {delta.get('github_stars', 'n/a')})", + f"- rating: **{record['rating']}** (Δ {delta.get('rating', 'n/a')})", + f"- search_ranking: **{record['search_ranking']}** (Δ {delta.get('search_ranking', 'n/a')})", + f"- latest_version: **{record['latest_version']}**" + + (" (changed)" if delta.get("version_changed") else ""), + f"- status: **{record['status']}**" + + (" (changed)" if delta.get("status_changed") else ""), + ] + with open(summary_path, "a", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + + +def main() -> int: + try: + payload = fetch_registry_data(REGISTRY_URL) + except RuntimeError as error: + print(f"::error::{error}", file=sys.stderr) + return 1 + + previous = read_last_record(HISTORY_FILE) + record = build_record(payload, previous) + append_record(HISTORY_FILE, record) + write_step_summary(record) + + print(f"Appended snapshot to {HISTORY_FILE}: {json.dumps(record, sort_keys=True)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())