v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 01:48:01 +08:00
co-authored by Cursor
parent 28858c0703
commit fdd926e9bb
11 changed files with 295 additions and 48 deletions
+76 -17
View File
@@ -3,9 +3,9 @@
Tiers (see AGENTS.md / Gitea Actions workflows):
daily — OpenDota stats, leaderboards, matches, pro matches, streamers,
streamer live probe; patch check
(live badge still trails reality by up to a day — truly real-time
would need a dedicated higher-frequency workflow, e.g. 15-min)
weekly — STRATZ meta, hero items, items_meta, item_fears
(live badge is served by /api/live-status on visit; daily probe is
only a data.json fallback until the edge API returns)
weekly — STRATZ meta, hero items, items_meta, item_counter_stats, item_fears
patch — patch list check; on has_new fetch details + version-linked scripts
all — weekly then daily (patch check included in daily/patch)
@@ -29,9 +29,11 @@ from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT.parent))
REPO_ROOT = ROOT.parent
sys.path.insert(0, str(REPO_ROOT))
from shared.http_utils import write_json_atomic
from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND
# Paths whose content change should trigger deploy / OSS.
DATA_WATCH = [
@@ -50,6 +52,21 @@ DATA_WATCH = [
ROOT / "data" / "item_shop.json",
ROOT / "data" / "item_counter_stats.json",
]
# Static site inputs that are not rewritten by fetch tiers but still ship in
# data.json / dist (frontend + qualitative relations + hero table / grid).
INPUT_WATCH = [
RELATIONS_JSON,
HEROES_JSON,
ROOT / "data" / "hero_grid_order.json",
WEB_FRONTEND / "index.html",
WEB_FRONTEND / "app.js",
WEB_FRONTEND / "style.css",
WEB_FRONTEND / "router.js",
WEB_FRONTEND / "mobile-gate.js",
WEB_FRONTEND / "config.js",
WEB_FRONTEND / "_headers",
]
FRONTEND_FUNCTIONS_DIR = WEB_FRONTEND / "functions"
ASSET_DIRS = [
ROOT / "assets" / "item_icons",
ROOT / "assets" / "ability_icons",
@@ -67,12 +84,24 @@ VOLATILE_JSON_KEYS = frozenset(
"live_probed_at",
"profile_fetched_at",
"updated_at",
# Live badge is owned by /api/live-status; flipping is_live alone must
# not trigger a full static redeploy.
"is_live",
}
)
RUN_RESULTS: list[dict] = []
PATCH_RESULT: dict = {}
def _watch_key(path: Path) -> str:
"""Stable snapshot key relative to the repo root when possible."""
resolved = path.resolve()
try:
return resolved.relative_to(REPO_ROOT.resolve()).as_posix()
except ValueError:
return resolved.as_posix()
def _file_digest(path: Path) -> str | None:
if not path.is_file():
return None
@@ -131,27 +160,39 @@ def _dir_digest(path: Path) -> str | None:
def snapshot() -> dict[str, str | None]:
out: dict[str, str | None] = {}
for p in DATA_WATCH:
out[str(p.relative_to(ROOT))] = _semantic_file_digest(p)
out[_watch_key(p)] = _semantic_file_digest(p)
for p in INPUT_WATCH:
# Frontend/source files use byte digests; JSON inputs use semantic hash.
if p.suffix.lower() == ".json":
out[_watch_key(p)] = _semantic_file_digest(p)
else:
out[_watch_key(p)] = _file_digest(p)
out[_watch_key(FRONTEND_FUNCTIONS_DIR) + "/"] = _dir_digest(FRONTEND_FUNCTIONS_DIR)
for d in ASSET_DIRS:
out[str(d.relative_to(ROOT)) + "/"] = _dir_digest(d)
out[_watch_key(d) + "/"] = _dir_digest(d)
return out
def diff_snapshots(before: dict[str, str | None], after: dict[str, str | None]) -> tuple[bool, bool]:
"""Return (data_changed, assets_changed)."""
"""Return (data_changed, assets_changed).
``data_changed`` covers generated JSON, frontend/source inputs, and Pages
Functions. Only ``web/assets/...`` directory digests count as assets
(OSS upload).
"""
data_changed = False
assets_changed = False
asset_keys = {_watch_key(d) + "/" for d in ASSET_DIRS}
keys = set(before) | set(after)
for k in keys:
if before.get(k) == after.get(k):
continue
if k.endswith("/"):
if k in asset_keys:
assets_changed = True
else:
data_changed = True
return data_changed, assets_changed
def run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False) -> bool:
cmd = [sys.executable, str(ROOT / script), *args]
print(f"+ {' '.join(cmd)}", flush=True)
@@ -234,9 +275,9 @@ def run_daily(*, dry_run: bool = False) -> bool:
run_script("fetch_hero_matches.py", "--source", "league", dry_run=dry_run)
run_script("fetch_pro_matches.py", "--include-pubs", dry_run=dry_run)
# Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss).
run_script("fetch_streamers.py", dry_run=dry_run)
# Soft-fail live probe (exits 0; probe failures keep previous is_live).
run_script("fetch_streamer_live.py", dry_run=dry_run)
run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True)
# Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at).
run_script("fetch_streamer_live.py", dry_run=dry_run, soft_fail=True)
check = patch_check()
if check.get("has_new"):
print(
@@ -323,11 +364,16 @@ def _stale_metrics() -> dict:
return metrics
def _write_summary(summary: dict) -> None:
def _finalize_summary(summary: dict) -> dict:
summary["steps"] = list(RUN_RESULTS)
summary["patch_check"] = dict(PATCH_RESULT)
summary["health"] = _stale_metrics()
summary["completed_at"] = datetime.now(timezone.utc).isoformat()
return summary
def _write_summary(summary: dict) -> None:
_finalize_summary(summary)
write_json_atomic(SUMMARY_PATH, summary)
print("REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False), flush=True)
@@ -441,7 +487,9 @@ def main() -> None:
_write_summary(summary)
return
try:
cache_enabled = os.environ.get("REFRESH_CACHE_ENABLED") == "1"
cache_enabled = (
os.environ.get("REFRESH_CACHE_ENABLED") == "1" and not args.dry_run
)
if cache_enabled:
run_script("refresh_cache.py", "restore")
before = snapshot() if not args.dry_run else {}
@@ -455,7 +503,10 @@ def main() -> None:
if args.dry_run:
summary["ok"] = True
summary["dry_run"] = True
print("dry-run complete (no deploy/oss)", flush=True)
print(
"dry-run complete (no cache restore/save, deploy, or oss)",
flush=True,
)
return
after = snapshot()
@@ -484,7 +535,7 @@ def main() -> None:
if assets_changed and not args.skip_oss:
run_script("_oss_static_assets.py", "upload")
elif assets_changed:
print("assets changed but deploy is disabled; skipping OSS upload", flush=True)
print("assets changed but --skip-oss set; skipping OSS upload", flush=True)
should_deploy = args.force_deploy or data_changed or assets_changed
if should_deploy and not args.skip_deploy:
@@ -502,7 +553,15 @@ def main() -> None:
raise
finally:
summary["duration_s"] = round(time.monotonic() - started, 3)
_write_summary(summary)
if args.dry_run:
# Print for humans; do not persist dry-run artifacts.
_finalize_summary(summary)
print(
"REFRESH_SUMMARY " + json.dumps(summary, ensure_ascii=False),
flush=True,
)
else:
_write_summary(summary)
release_refresh_lock(lock_handle)