Skip to content

Commit 28aa777

Browse files
strimo378claudeandife
authored
Add scoreboard freshness check that opens an issue on stale data (#123)
* Add scoreboard freshness check that opens an issue on stale data Adds a daily workflow that fetches the published scoreboard page (index.html), reads each backend's last update date from the embedded data and opens a tracking issue when any backend is older than 3 days. To avoid issue spam a single labelled issue is reused: it is created on the first stale detection, refreshed silently while it stays stale, and closed automatically once all backends are up to date again. - scripts/check_scoreboard_freshness.py: parser + freshness evaluation, writes results to GITHUB_OUTPUT (configurable --max-age-days, default 3) - .github/workflows/scoreboard-freshness.yml: daily schedule + manual dispatch, manages the tracking issue via actions/github-script - .gitignore: whitelist the new script (scripts/* is ignored by default) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EeeD61kc68kKLbHuSmahUc Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com> * Pin GitHub Actions to commit SHAs Addresses zizmor code-scanning findings: actions must be pinned to a full commit hash per the repo's blanket policy. - actions/checkout -> de0fac2 (v6.0.2, matches existing workflows) - actions/setup-python -> a309ff8 (v6.2.0) - actions/github-script -> f28e40c (v7.1.0) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com> * Fix scoreboard freshness review comments Signed-off-by: Andreas Fehlner <fehlner@arcor.de> * Fix CodeQL issue in freshness test Signed-off-by: Andreas Fehlner <fehlner@arcor.de> * Fix Sonar findings in freshness script Signed-off-by: Andreas Fehlner <fehlner@arcor.de> --------- Signed-off-by: Timo Stripf <timo.stripf@emmtrix.com> Signed-off-by: Andreas Fehlner <fehlner@arcor.de> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Andreas Fehlner <fehlner@arcor.de>
1 parent ea94be6 commit 28aa777

4 files changed

Lines changed: 449 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) ONNX Project Contributors
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
name: Scoreboard freshness check
6+
7+
on:
8+
schedule:
9+
# Run a few hours after the website is rebuilt/deployed.
10+
- cron: '0 9 * * *'
11+
workflow_dispatch:
12+
13+
permissions: {}
14+
15+
jobs:
16+
check_freshness:
17+
runs-on: ubuntu-latest
18+
permissions:
19+
contents: read
20+
issues: write
21+
steps:
22+
- name: Checkout code
23+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24+
with:
25+
persist-credentials: false
26+
27+
- name: Set up Python 3.12
28+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
29+
with:
30+
python-version: '3.12'
31+
32+
- name: Check published scoreboard freshness
33+
id: check
34+
run: |
35+
python3 scripts/check_scoreboard_freshness.py \
36+
--url "https://onnx.ai/backend-scoreboard/index.html" \
37+
--max-age-days 3
38+
39+
- name: Open, update or close the tracking issue
40+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
41+
env:
42+
STALE: ${{ steps.check.outputs.stale }}
43+
STALE_BACKENDS: ${{ steps.check.outputs.stale_backends }}
44+
ISSUE_BODY: ${{ steps.check.outputs.body }}
45+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
46+
with:
47+
script: |
48+
const label = 'scoreboard-stale';
49+
const title = '⚠️ Scoreboard data is out of date';
50+
const stale = process.env.STALE === 'true';
51+
const footer = `\n\n---\n_Automated by [Scoreboard freshness check](${process.env.RUN_URL})._`;
52+
const body = (process.env.ISSUE_BODY || '').trim() + footer;
53+
54+
try {
55+
await github.rest.issues.getLabel({
56+
owner: context.repo.owner,
57+
repo: context.repo.repo,
58+
name: label,
59+
});
60+
} catch (error) {
61+
if (error.status !== 404) {
62+
throw error;
63+
}
64+
await github.rest.issues.createLabel({
65+
owner: context.repo.owner,
66+
repo: context.repo.repo,
67+
name: label,
68+
color: 'd73a4a',
69+
description: 'Tracking issue for stale backend scoreboard data',
70+
});
71+
}
72+
73+
const existing = await github.paginate(github.rest.issues.listForRepo, {
74+
owner: context.repo.owner,
75+
repo: context.repo.repo,
76+
state: 'open',
77+
labels: label,
78+
per_page: 100,
79+
});
80+
const issue = existing.find(i => i.title === title);
81+
82+
if (stale) {
83+
if (issue) {
84+
// Refresh the body silently so we don't spam subscribers daily.
85+
await github.rest.issues.update({
86+
owner: context.repo.owner,
87+
repo: context.repo.repo,
88+
issue_number: issue.number,
89+
body,
90+
});
91+
core.info(`Updated existing issue #${issue.number}`);
92+
} else {
93+
const created = await github.rest.issues.create({
94+
owner: context.repo.owner,
95+
repo: context.repo.repo,
96+
title,
97+
body,
98+
labels: [label],
99+
});
100+
core.info(`Created issue #${created.data.number}`);
101+
}
102+
} else if (issue) {
103+
await github.rest.issues.createComment({
104+
owner: context.repo.owner,
105+
repo: context.repo.repo,
106+
issue_number: issue.number,
107+
body: `✅ All scoreboard backends are up to date again as of this run.${footer}`,
108+
});
109+
await github.rest.issues.update({
110+
owner: context.repo.owner,
111+
repo: context.repo.repo,
112+
issue_number: issue.number,
113+
state: 'closed',
114+
});
115+
core.info(`Closed resolved issue #${issue.number}`);
116+
} else {
117+
core.info('Scoreboard is fresh; nothing to do.');
118+
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Developer scripts for test only
22
scripts/*
33
!scripts/merge_trends.py
4+
!scripts/check_scoreboard_freshness.py
45

56
# Byte-compiled / optimized / DLL files
67
__pycache__/
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Copyright (c) ONNX Project Contributors
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Check whether the published scoreboard page shows up-to-date data.
6+
7+
The script downloads the rendered scoreboard page (by default the public
8+
``index.html``), reads the per-backend "Date" values that the website renders
9+
from ``trend[-1].date`` and reports any backend whose latest update is older
10+
than a configurable threshold (default: 3 days).
11+
12+
It is meant to run in CI: results are written to ``GITHUB_OUTPUT`` so a
13+
follow-up step can open / update / close a single tracking issue.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import argparse
19+
import json
20+
import os
21+
import re
22+
import sys
23+
import urllib.request
24+
25+
from datetime import datetime, timezone
26+
from pathlib import Path
27+
from urllib.parse import urlparse
28+
29+
30+
DEFAULT_URL = "https://onnx.ai/backend-scoreboard/index.html"
31+
32+
# The website embeds the full scoreboard as JSON in
33+
# <div id="content" database='{...}'>. The visible "Date" column is rendered
34+
# from each backend's last trend entry (trend[-1].date).
35+
DATABASE_RE = re.compile(r"database='(.*?)'>", re.DOTALL)
36+
37+
# Date format produced by website-generator/generator.py:
38+
# datetime.now().strftime("%m/%d/%Y %H:%M:%S")
39+
DATE_FORMAT = "%m/%d/%Y %H:%M:%S"
40+
41+
42+
def fetch_html(url: str, timeout: int = 60) -> str:
43+
"""Download the scoreboard page."""
44+
parsed = urlparse(url)
45+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
46+
raise ValueError(f"Unsupported scoreboard URL: {url!r}")
47+
if parsed.username or parsed.password:
48+
raise ValueError("Scoreboard URL must not include credentials")
49+
headers = {"User-Agent": "scoreboard-freshness-check"}
50+
request = urllib.request.Request(url, headers=headers)
51+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
52+
charset = response.headers.get_content_charset() or "utf-8"
53+
return response.read().decode(charset, errors="replace")
54+
55+
56+
def load_html_file(path: str) -> str:
57+
"""Read and return a validated local HTML file."""
58+
html_path = Path(path).expanduser().resolve(strict=True)
59+
if not html_path.is_file():
60+
raise ValueError(f"Scoreboard HTML path must point to a file: {path!r}")
61+
return html_path.read_text(encoding="utf-8")
62+
63+
64+
def extract_database(html: str) -> dict:
65+
"""Extract and parse the embedded scoreboard database JSON."""
66+
match = DATABASE_RE.search(html)
67+
if not match:
68+
raise ValueError("Could not find the 'database' payload in the page")
69+
return json.loads(match.group(1))
70+
71+
72+
def latest_date(backend_data: dict) -> str | None:
73+
"""Return the most recent trend date string for a backend, if any."""
74+
trend = backend_data.get("trend") or []
75+
if not trend:
76+
return None
77+
return trend[-1].get("date")
78+
79+
80+
def parse_date(value: str) -> datetime:
81+
"""Parse a scoreboard date string as a UTC datetime."""
82+
return datetime.strptime(value, DATE_FORMAT).replace(tzinfo=timezone.utc)
83+
84+
85+
def evaluate(database: dict, max_age_days: float, now: datetime) -> list[dict]:
86+
"""Return a per-backend freshness report sorted by descending age."""
87+
report = []
88+
for backend, backend_data in database.items():
89+
name = backend_data.get("name", backend)
90+
date_str = latest_date(backend_data)
91+
92+
if not date_str:
93+
report.append(
94+
{
95+
"backend": backend,
96+
"name": name,
97+
"date": None,
98+
"age_days": None,
99+
"stale": True,
100+
"reason": "no trend data",
101+
}
102+
)
103+
continue
104+
105+
try:
106+
updated = parse_date(date_str)
107+
except ValueError:
108+
report.append(
109+
{
110+
"backend": backend,
111+
"name": name,
112+
"date": date_str,
113+
"age_days": None,
114+
"stale": True,
115+
"reason": "unparseable date",
116+
}
117+
)
118+
continue
119+
120+
age_days = (now - updated).total_seconds() / 86400.0
121+
report.append(
122+
{
123+
"backend": backend,
124+
"name": name,
125+
"date": date_str,
126+
"age_days": age_days,
127+
"stale": age_days > max_age_days,
128+
"reason": "",
129+
}
130+
)
131+
132+
def sort_key(row: dict) -> float:
133+
return -1.0 if row["age_days"] is None else row["age_days"]
134+
135+
report.sort(key=sort_key, reverse=True)
136+
return report
137+
138+
139+
def render_markdown(
140+
report: list[dict], url: str, max_age_days: float, now: datetime
141+
) -> str:
142+
"""Build a Markdown summary suitable for an issue body."""
143+
stale = [row for row in report if row["stale"]]
144+
lines = [
145+
f"The scoreboard page reports data older than **{max_age_days:g} days** "
146+
f"for {len(stale)} of {len(report)} backend(s).",
147+
"",
148+
f"- Source: {url}",
149+
f"- Checked at: {now.strftime('%Y-%m-%d %H:%M:%S')} UTC",
150+
"",
151+
"| Backend | Last update (UTC) | Age | Status |",
152+
"| --- | --- | --- | --- |",
153+
]
154+
for row in report:
155+
if row["age_days"] is None:
156+
age = row["reason"] or "unknown"
157+
else:
158+
age = f"{row['age_days']:.1f} days"
159+
status = "⚠️ stale" if row["stale"] else "✅ ok"
160+
lines.append(f"| {row['name']} | {row['date'] or '—'} | {age} | {status} |")
161+
return "\n".join(lines)
162+
163+
164+
def write_outputs(stale: bool, report: list[dict], body: str) -> None:
165+
"""Expose results to later workflow steps via GITHUB_OUTPUT."""
166+
output_path = os.environ.get("GITHUB_OUTPUT")
167+
if not output_path:
168+
return
169+
stale_names = ", ".join(row["name"] for row in report if row["stale"])
170+
with open(output_path, "a", encoding="utf-8") as handle:
171+
handle.write(f"stale={'true' if stale else 'false'}\n")
172+
handle.write(f"stale_count={sum(1 for row in report if row['stale'])}\n")
173+
handle.write(f"stale_backends={stale_names}\n")
174+
handle.write("body<<FRESHNESS_EOF\n")
175+
handle.write(body + "\n")
176+
handle.write("FRESHNESS_EOF\n")
177+
178+
179+
def main(argv: list[str] | None = None) -> int:
180+
"""Run the freshness check and emit a report plus CI outputs."""
181+
parser = argparse.ArgumentParser(description=__doc__)
182+
parser.add_argument("--url", default=DEFAULT_URL, help="Scoreboard page URL")
183+
parser.add_argument(
184+
"--html-file",
185+
help="Read the page from a local file instead of fetching it (for testing)",
186+
)
187+
parser.add_argument(
188+
"--max-age-days",
189+
type=float,
190+
default=3.0,
191+
help="A backend is stale if its latest date is older than this many days "
192+
"(default: 3)",
193+
)
194+
args = parser.parse_args(argv)
195+
196+
source = args.html_file or args.url
197+
try:
198+
if args.html_file:
199+
html = load_html_file(args.html_file)
200+
else:
201+
html = fetch_html(args.url)
202+
database = extract_database(html)
203+
except Exception as error: # noqa: BLE001 - surface any failure as a stale signal
204+
message = f"Failed to read scoreboard data from {source}: {error}"
205+
print(message, file=sys.stderr)
206+
body = (
207+
f"The freshness check could not read the scoreboard page.\n\n"
208+
f"- Source: {source}\n- Error: `{error}`"
209+
)
210+
write_outputs(True, [], body)
211+
return 1
212+
213+
now = datetime.now(timezone.utc)
214+
report = evaluate(database, args.max_age_days, now)
215+
stale = any(row["stale"] for row in report)
216+
body = render_markdown(report, source, args.max_age_days, now)
217+
218+
print(body)
219+
write_outputs(stale, report, body)
220+
return 0
221+
222+
223+
if __name__ == "__main__":
224+
raise SystemExit(main())

0 commit comments

Comments
 (0)