feat: deploy star history via GitHub Pages

This commit is contained in:
MarSeventh
2026-07-28 21:05:18 +08:00
parent 062b8bafed
commit 098cd44109
5 changed files with 4695 additions and 8 deletions
File diff suppressed because it is too large Load Diff
+840
View File
@@ -0,0 +1,840 @@
#!/usr/bin/env python3
"""Maintain and render Star History without a hosted chart service.
The three subcommands form one pipeline:
* ``refresh-data`` extends the public date/count cache with one GitHub
repository-metadata request per configured repository;
* ``patch-upstream`` seeds a pinned official backend from that local cache;
* ``render`` validates and saves the official localhost-rendered SVG pair.
The official JSDOM, XYChart, xkcd styling, theme logic, and SVGO rendering path
remain unchanged.
"""
from __future__ import annotations
import argparse
import base64
import copy
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
DEFAULT_BACKEND_URL = "http://127.0.0.1:8080"
SCHEMA_VERSION = 2
DATA_FORMAT = "star-history-pages"
THEMES = ("light", "dark")
REPOSITORY_PATTERN = re.compile(
r"^[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?/"
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$"
)
USER_AGENT = "deploy-star-history-pages local updater"
MAX_JSON_BYTES = 5_000_000
MAX_SVG_BYTES = 5_000_000
# One retry covers a short runner-local startup/readiness race. The backend is
# seeded from local data and therefore performs no stargazers API fan-out.
LOCAL_RENDER_ATTEMPTS = 2
# The fallback fetches the last-deployed pair from this project's Pages CDN,
# where retries usefully ride out short-lived network hiccups.
FALLBACK_RENDER_ATTEMPTS = 4
MAIN_PATH = Path("backend/main.ts")
STARTUP_ORIGINAL = """const startServer = async () => {
await initTokenFromEnv();
initOgAssets();
const repoStore = loadRepos();
const app = new Hono();"""
STARTUP_PATCHED = """const startServer = async () => {
const seedPath = process.env.STAR_HISTORY_DATA_PATH;
if (!seedPath) {
await initTokenFromEnv();
}
initOgAssets();
const repoStore = loadRepos();
if (seedPath) {
const fs = await import("node:fs");
const seed = JSON.parse(fs.readFileSync(seedPath, "utf8"));
const series = seed.series;
if (!Array.isArray(series) || series.length === 0) {
throw new Error("Invalid STAR_HISTORY_DATA_PATH payload");
}
for (const item of series) {
const repository = String(item.repository || "").toLowerCase();
const starRecords = item.star_records;
const logoUrl = String(item.logo_url || "");
if (!repository || !Array.isArray(starRecords) || starRecords.length === 0 || !logoUrl) {
throw new Error("Invalid STAR_HISTORY_DATA_PATH series");
}
cache.set(repository, {
starRecords,
starAmount: starRecords[starRecords.length - 1].count,
logoUrl,
});
logger.info(`Loaded ${starRecords.length} local Star History records for ${repository}`);
}
}
const app = new Hono();"""
# ---------------------------------------------------------------------------
# Shared command-line interface
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
refresh = commands.add_parser(
"refresh-data",
help="Update the local date/count cache from GitHub repository metadata",
)
refresh.add_argument(
"--repository",
default=os.environ.get("STAR_HISTORY_REPOSITORIES"),
required="STAR_HISTORY_REPOSITORIES" not in os.environ,
help="Comma-separated GitHub repositories in owner/name form",
)
refresh.add_argument("--seed-file", type=Path, required=True)
refresh.add_argument("--deployed-url")
refresh.add_argument("--output", type=Path, required=True)
refresh.add_argument(
"--token-env",
default="GITHUB_TOKEN",
help="Environment variable containing a GitHub token",
)
patch_parser = commands.add_parser(
"patch-upstream",
help="Patch the pinned official backend to load the local cache",
)
patch_parser.add_argument(
"--source-dir",
type=Path,
required=True,
help="Path to the checked-out star-history/star-history source",
)
render = commands.add_parser(
"render",
help="Save light and dark SVGs produced by the local official backend",
)
render.add_argument(
"--backend-url",
default=os.environ.get("STAR_HISTORY_BACKEND_URL", DEFAULT_BACKEND_URL),
help="Local official backend origin (default: %(default)s)",
)
render.add_argument(
"--repository",
default=os.environ.get("STAR_HISTORY_REPOSITORIES"),
required="STAR_HISTORY_REPOSITORIES" not in os.environ,
help="Comma-separated GitHub repositories in owner/name form",
)
render.add_argument(
"--output-dir",
type=Path,
default=Path(".star-history-site"),
help="Directory that receives the two SVG files (default: %(default)s)",
)
render.add_argument(
"--fallback-base-url",
default=os.environ.get("STAR_HISTORY_FALLBACK_BASE_URL"),
help=(
"Base URL containing this project's last deployed light/dark SVG pair"
),
)
return parser
# ---------------------------------------------------------------------------
# History data cache
# ---------------------------------------------------------------------------
def parse_record_date(value: str) -> datetime:
return datetime.strptime(value, "%Y/%m/%d %H:%M:%S").replace(
tzinfo=timezone.utc
)
def format_record_date(value: datetime) -> str:
current = value.astimezone(timezone.utc)
return "{0}/{1}/{2} {3}:{4}:{5}".format(
current.year,
current.month,
current.day,
current.hour,
current.minute,
current.second,
)
def validate_repository(value: str) -> str:
repository = value.strip().lower()
if not REPOSITORY_PATTERN.fullmatch(repository):
raise ValueError("repository must use GitHub owner/name format")
return repository
def validate_repositories(value: str) -> Tuple[str, ...]:
repositories = tuple(validate_repository(item) for item in value.split(","))
if not repositories or len(set(repositories)) != len(repositories):
raise ValueError("repositories must be a non-empty unique list")
return repositories
def _validate_runtime_series(payload: Any, repository: str) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("Star History series must be a JSON object")
if str(payload.get("repository", "")).lower() != repository.lower():
raise ValueError("Star History series targets a different repository")
logo_url = payload.get("logo_url")
if not isinstance(logo_url, str) or not logo_url.startswith("data:image/"):
raise ValueError("Star History series is missing an embedded logo")
records = payload.get("star_records")
if not isinstance(records, list) or len(records) < 2:
raise ValueError("Star History data requires at least two records")
previous_date: Optional[datetime] = None
for record in records:
if not isinstance(record, dict):
raise ValueError("Star History record must be an object")
date_value = record.get("date")
if not isinstance(date_value, str):
raise ValueError("Star History record date must be a string")
date = parse_record_date(date_value)
count = record.get("count")
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise ValueError("Star History record count must be a non-negative integer")
if previous_date is not None and date <= previous_date:
raise ValueError("Star History records must be strictly chronological")
previous_date = date
return payload
def validate_data(
payload: Any, repositories: Tuple[str, ...]
) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise ValueError("Star History data must be a JSON object")
if payload.get("format") != DATA_FORMAT:
raise ValueError("unsupported Star History data format")
if payload.get("schema_version") != SCHEMA_VERSION:
raise ValueError("unsupported Star History data schema")
series = payload.get("series")
if not isinstance(series, list) or len(series) != len(repositories):
raise ValueError("Star History data must contain every repository")
for index, repository in enumerate(repositories):
_validate_runtime_series(series[index], repository)
updated_at = payload.get("updated_at")
if not isinstance(updated_at, str):
raise ValueError("Star History data is missing updated_at")
datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
return payload
def validate_legacy_seed(
payload: Any, repositories: Tuple[str, ...]
) -> Dict[str, Any]:
if not isinstance(payload, dict) or payload.get("schema_version") != 2:
raise ValueError("legacy Star History seed has an unsupported schema")
series = payload.get("series")
if not isinstance(series, list) or len(series) != len(repositories):
raise ValueError("legacy Star History seed must contain every repository")
for index, repository in enumerate(repositories):
item = series[index]
if not isinstance(item, dict) or str(item.get("repository", "")).lower() != repository:
raise ValueError("legacy Star History repository order is invalid")
reconstruction = item.get("reconstruction")
snapshots = item.get("snapshots")
if not isinstance(reconstruction, dict) or not isinstance(snapshots, list):
raise ValueError("legacy Star History series is malformed")
daily = reconstruction.get("daily")
if not isinstance(daily, list):
raise ValueError("legacy Star History reconstruction is malformed")
for point in daily:
datetime.fromisoformat(point["date"])
count = point.get("stars")
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise ValueError("legacy Star History count is invalid")
for snapshot in snapshots:
datetime.fromisoformat(str(snapshot["at"]).replace("Z", "+00:00"))
count = snapshot.get("stars")
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise ValueError("legacy Star History snapshot is invalid")
return payload
def read_json_file(path: Path) -> Dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def download_json(url: str) -> Dict[str, Any]:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.netloc:
raise ValueError("deployed data URL must use HTTPS")
request = urllib.request.Request(
url,
headers={"User-Agent": USER_AGENT},
)
with urllib.request.urlopen(request, timeout=30) as response:
body = response.read(MAX_JSON_BYTES + 1)
if len(body) > MAX_JSON_BYTES:
raise ValueError("deployed Star History data is unexpectedly large")
return json.loads(body.decode("utf-8"))
def load_best_data(
*,
repositories: Tuple[str, ...],
seed_file: Path,
deployed_url: Optional[str],
) -> Tuple[Dict[str, Any], str]:
if deployed_url:
try:
deployed = validate_data(download_json(deployed_url), repositories)
return deployed, "deployed Pages data"
except urllib.error.HTTPError as exc:
try:
status = exc.code
finally:
exc.close()
if status != 404:
raise RuntimeError(
"deployed Pages cache request failed; preserving the last "
"deployment: HTTP {0}".format(status)
) from exc
print("[data] deployed cache not found; using repository seed")
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
raise RuntimeError(
"deployed Pages cache is unavailable or invalid; preserving "
"the last deployment: {0}".format(exc)
) from exc
seed = read_json_file(seed_file)
try:
return validate_data(seed, repositories), "repository seed"
except ValueError:
return validate_legacy_seed(seed, repositories), "legacy repository seed"
def fetch_repository_metadata(repository: str, token: str) -> Dict[str, Any]:
url = "https://api.github.com/repos/{0}".format(repository)
request = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"Authorization": "Bearer {0}".format(token),
"User-Agent": USER_AGENT,
"X-GitHub-Api-Version": "2022-11-28",
},
)
last_error: Optional[Exception] = None
for attempt in range(1, 4):
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.loads(response.read().decode("utf-8"))
count = payload.get("stargazers_count")
if isinstance(count, bool) or not isinstance(count, int) or count < 0:
raise ValueError("GitHub metadata omitted stargazers_count")
created_at = payload.get("created_at")
owner = payload.get("owner")
avatar_url = owner.get("avatar_url") if isinstance(owner, dict) else None
if not isinstance(created_at, str) or not created_at:
raise ValueError("GitHub metadata omitted created_at")
if not isinstance(avatar_url, str) or not avatar_url:
raise ValueError("GitHub metadata omitted owner.avatar_url")
return {
"star_count": count,
"created_at": created_at,
"avatar_url": avatar_url,
}
except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as exc:
last_error = exc
if attempt < 3:
time.sleep(attempt * 2)
raise RuntimeError("GitHub repository metadata request failed: {0}".format(last_error))
def download_avatar_data_url(url: str) -> str:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or parsed.hostname != "avatars.githubusercontent.com":
raise ValueError("GitHub metadata returned an unexpected avatar URL")
request = urllib.request.Request(
url + ("&" if parsed.query else "?") + "size=22",
headers={"User-Agent": USER_AGENT},
)
with urllib.request.urlopen(request, timeout=30) as response:
content_type = response.headers.get_content_type()
body = response.read(2_000_001)
if not content_type.startswith("image/") or len(body) > 2_000_000:
raise ValueError("GitHub avatar response is not a supported image")
return "data:{0};base64,{1}".format(
content_type,
base64.b64encode(body).decode("ascii"),
)
def update_current_record(
payload: Dict[str, Any],
*,
repository: str,
star_count: int,
current_time: datetime,
) -> Tuple[Dict[str, Any], bool]:
updated = copy.deepcopy(payload)
_validate_runtime_series(updated, repository)
current = current_time.astimezone(timezone.utc)
record = {"date": format_record_date(current), "count": star_count}
records = updated["star_records"]
latest = parse_record_date(records[-1]["date"])
if current < latest:
raise ValueError("current time predates the latest Star History record")
if latest.date() == current.date():
if records[-1]["count"] == star_count:
return updated, False
records[-1] = record
else:
records.append(record)
_validate_runtime_series(updated, repository)
return updated, True
def migrate_legacy_seed(
payload: Dict[str, Any],
*,
repositories: Tuple[str, ...],
metadata: Dict[str, Dict[str, Any]],
current_time: datetime,
) -> Dict[str, Any]:
validate_legacy_seed(payload, repositories)
avatar_cache: Dict[str, str] = {}
runtime_series: List[Dict[str, Any]] = []
for index, repository in enumerate(repositories):
legacy = payload["series"][index]
records_by_time: Dict[datetime, int] = {}
for point in legacy["reconstruction"]["daily"]:
point_day = datetime.fromisoformat(point["date"]).date()
end_of_day = datetime(
point_day.year,
point_day.month,
point_day.day,
tzinfo=timezone.utc,
) + timedelta(days=1)
records_by_time[end_of_day] = point["stars"]
for snapshot in legacy["snapshots"]:
at = datetime.fromisoformat(snapshot["at"].replace("Z", "+00:00"))
records_by_time[at.astimezone(timezone.utc)] = snapshot["stars"]
records = [
{"date": format_record_date(at), "count": records_by_time[at]}
for at in sorted(records_by_time)
]
avatar_url = metadata[repository]["avatar_url"]
if avatar_url not in avatar_cache:
avatar_cache[avatar_url] = download_avatar_data_url(avatar_url)
series = {
"repository": repository,
"logo_url": avatar_cache[avatar_url],
"star_records": records,
}
series, _ = update_current_record(
series,
repository=repository,
star_count=metadata[repository]["star_count"],
current_time=current_time,
)
runtime_series.append(series)
migrated = {
"format": DATA_FORMAT,
"schema_version": SCHEMA_VERSION,
"updated_at": current_time.isoformat().replace("+00:00", "Z"),
"series": runtime_series,
}
return validate_data(migrated, repositories)
def atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
content = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
prefix=path.name + ".",
suffix=".tmp",
dir=path.parent,
delete=False,
) as temporary:
temporary.write(content)
temporary_path = Path(temporary.name)
temporary_path.replace(path)
path.chmod(0o644)
def refresh_data_command(args: argparse.Namespace) -> int:
try:
repositories = validate_repositories(args.repository)
except ValueError as exc:
print("[error] {0}".format(exc))
return 1
token = os.environ.get(args.token_env, "").strip()
if not token:
print("[error] GitHub token environment variable is empty")
return 1
try:
payload, source = load_best_data(
repositories=repositories,
seed_file=args.seed_file,
deployed_url=args.deployed_url,
)
metadata = {
repository: fetch_repository_metadata(repository, token)
for repository in repositories
}
current_time = datetime.now(timezone.utc)
if payload.get("format") != DATA_FORMAT:
updated = migrate_legacy_seed(
payload,
repositories=repositories,
metadata=metadata,
current_time=current_time,
)
source = "legacy repository seed"
else:
updated = copy.deepcopy(payload)
changed = False
for index, repository in enumerate(repositories):
series, series_changed = update_current_record(
updated["series"][index],
repository=repository,
star_count=metadata[repository]["star_count"],
current_time=current_time,
)
updated["series"][index] = series
changed = changed or series_changed
if changed:
updated["updated_at"] = current_time.isoformat().replace("+00:00", "Z")
validate_data(updated, repositories)
atomic_write_json(args.output, updated)
except (
KeyError,
TypeError,
OSError,
RuntimeError,
ValueError,
json.JSONDecodeError,
) as exc:
print("[error] Star History data refresh failed: {0}".format(exc))
return 1
for index, repository in enumerate(repositories):
print(
"[data] {0}: {1} stars from {2} ({3} records)".format(
repository,
metadata[repository]["star_count"],
source,
len(updated["series"][index]["star_records"]),
)
)
return 0
# ---------------------------------------------------------------------------
# Guarded official-backend patch
# ---------------------------------------------------------------------------
def replace_exactly_once(
path: Path,
original: str,
replacement: str,
description: str,
) -> bool:
"""Replace one pinned-upstream fragment and reject silent source drift."""
source = path.read_text(encoding="utf-8")
original_count = source.count(original)
replacement_count = source.count(replacement)
if original_count == 0 and replacement_count == 1:
return False
if original_count != 1 or replacement_count != 0:
raise RuntimeError(
"unexpected upstream implementation for {0} in {1} "
"(original={2}, patched={3})".format(
description,
path,
original_count,
replacement_count,
)
)
path.write_text(source.replace(original, replacement), encoding="utf-8")
return True
def patch_upstream(source_dir: Path) -> List[Path]:
"""Patch the pinned checkout and return the files changed in this call."""
path = source_dir / MAIN_PATH
if not path.is_file():
raise RuntimeError("missing pinned upstream file: {0}".format(path))
changed = replace_exactly_once(
path,
STARTUP_ORIGINAL,
STARTUP_PATCHED,
"local data cache seed",
)
return [MAIN_PATH] if changed else []
def patch_upstream_command(args: argparse.Namespace) -> int:
try:
changed = patch_upstream(args.source_dir)
except (OSError, RuntimeError) as exc:
print("[error] Star History upstream patch failed: {0}".format(exc))
return 1
if changed:
for path in changed:
print("[patched] {0}".format(path))
else:
print("[current] Star History upstream patch is already applied")
return 0
# ---------------------------------------------------------------------------
# Official localhost SVG rendering and pair fallback
# ---------------------------------------------------------------------------
def validate_local_backend_url(value: str) -> str:
parsed = urllib.parse.urlsplit(value.rstrip("/"))
if parsed.scheme != "http" or parsed.hostname not in {"127.0.0.1", "localhost"}:
raise ValueError("backend URL must point to a local HTTP server")
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise ValueError("backend URL must be an origin without a path or query")
return value.rstrip("/")
def validate_fallback_base_url(value: str) -> str:
normalized = value.rstrip("/")
parsed = urllib.parse.urlsplit(normalized)
is_local_http = parsed.scheme == "http" and parsed.hostname in {
"127.0.0.1",
"localhost",
}
if parsed.scheme != "https" and not is_local_http:
raise ValueError("fallback URL must use HTTPS or local HTTP")
if not parsed.netloc or parsed.username or parsed.password:
raise ValueError("fallback URL must contain a host without credentials")
if parsed.query or parsed.fragment:
raise ValueError("fallback URL must not contain a query or fragment")
return normalized
def chart_url(
backend_url: str, repositories: Tuple[str, ...], theme: str
) -> str:
params = {
"repos": ",".join(repositories),
"type": "date",
"legend": "top-left",
}
if theme == "dark":
params["theme"] = "dark"
return "{0}/svg?{1}".format(backend_url, urllib.parse.urlencode(params))
def fallback_chart_url(base_url: str, theme: str) -> str:
return "{0}/star-history-{1}.svg".format(base_url, theme)
def emit_github_warning(message: str) -> None:
"""Surface a degraded refresh as an Actions annotation."""
if os.environ.get("GITHUB_ACTIONS") != "true":
return
single_line = message.replace("\r", " ").replace("\n", " ")
print("::warning title=Star History::{0}".format(single_line))
def download_svg(url: str, attempts: int = 4) -> bytes:
request = urllib.request.Request(
url,
headers={
"Accept": "image/svg+xml",
"User-Agent": "deploy-star-history-pages local renderer",
},
)
last_error: Optional[Exception] = None
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(request, timeout=120) as response:
content_type = response.headers.get("Content-Type", "").lower()
body = response.read(MAX_SVG_BYTES + 1)
if "svg" not in content_type:
raise ValueError(
"unexpected Content-Type: {0}".format(
content_type or "(missing)"
)
)
if len(body) > MAX_SVG_BYTES:
raise ValueError("SVG response is unexpectedly large")
return body
except urllib.error.HTTPError as exc:
try:
response_body = exc.read().decode("utf-8", errors="replace").strip()
finally:
exc.close()
detail = "HTTP {0} {1}".format(exc.code, exc.reason)
if response_body:
detail += ": {0}".format(response_body)
last_error = RuntimeError(detail)
if attempt == attempts:
break
time.sleep(attempt * 2)
except (OSError, ValueError, urllib.error.URLError) as exc:
last_error = exc
if attempt == attempts:
break
time.sleep(attempt * 2)
raise RuntimeError("local Star History backend request failed: {0}".format(last_error))
def validate_svg(
content: bytes, repositories: Tuple[str, ...], theme: str
) -> None:
if len(content) < 10_000:
raise ValueError("SVG is unexpectedly small: {0} bytes".format(len(content)))
root = ET.fromstring(content)
if root.tag.rsplit("}", 1)[-1] != "svg":
raise ValueError("document root is not SVG: {0}".format(root.tag))
if root.attrib.get("width") != "800" or root.attrib.get("height") != "533.333":
raise ValueError(
"official laptop chart dimensions changed: {0}x{1}".format(
root.attrib.get("width"),
root.attrib.get("height"),
)
)
text = content.decode("utf-8")
required_fragments = (
"Star History",
"GitHub Stars",
"xkcdify",
"font-family:xkcd",
)
required_fragments += repositories
missing = [fragment for fragment in required_fragments if fragment not in text]
if missing:
raise ValueError(
"SVG is missing official chart markers: {0}".format(", ".join(missing))
)
expected_background = "background:#0d1117" if theme == "dark" else "background:#fff"
if expected_background not in text:
raise ValueError("SVG does not contain the expected {0} theme".format(theme))
def atomic_write_svg(destination: Path, content: bytes) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=destination.name + ".",
suffix=".tmp",
dir=destination.parent,
delete=False,
) as temporary:
temporary.write(content)
temporary_path = Path(temporary.name)
temporary_path.replace(destination)
destination.chmod(0o644)
print("[rendered] {0} ({1} bytes)".format(destination, len(content)))
def fetch_chart_pair(
*,
repositories: Tuple[str, ...],
url_for_theme,
attempts: int,
) -> Dict[str, bytes]:
charts = {}
for theme in THEMES:
content = download_svg(url_for_theme(theme), attempts=attempts)
validate_svg(content, repositories, theme)
charts[theme] = content
return charts
def render_command(args: argparse.Namespace) -> int:
try:
backend_url = validate_local_backend_url(args.backend_url)
repositories = validate_repositories(args.repository)
try:
charts = fetch_chart_pair(
repositories=repositories,
url_for_theme=lambda theme: chart_url(
backend_url,
repositories,
theme,
),
attempts=LOCAL_RENDER_ATTEMPTS,
)
except (RuntimeError, OSError, ValueError, ET.ParseError) as local_error:
if not args.fallback_base_url:
raise
fallback_base_url = validate_fallback_base_url(args.fallback_base_url)
warning = (
"Local Star History refresh failed; reusing the last deployed "
"chart pair: {0}".format(local_error)
)
print("[warning] {0}".format(warning), file=sys.stderr)
emit_github_warning(warning)
charts = fetch_chart_pair(
repositories=repositories,
url_for_theme=lambda theme: fallback_chart_url(
fallback_base_url,
theme,
),
attempts=FALLBACK_RENDER_ATTEMPTS,
)
# Write only after both themes validate, preventing a mixed fresh/stale pair.
for theme in THEMES:
atomic_write_svg(
args.output_dir / "star-history-{0}.svg".format(theme),
charts[theme],
)
except (RuntimeError, OSError, ValueError, ET.ParseError) as exc:
print("[error] Star History rendering failed: {0}".format(exc), file=sys.stderr)
return 1
return 0
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "refresh-data":
return refresh_data_command(args)
if args.command == "patch-upstream":
return patch_upstream_command(args)
if args.command == "render":
return render_command(args)
raise RuntimeError("unknown Star History command: {0}".format(args.command))
if __name__ == "__main__":
raise SystemExit(main())
+230
View File
@@ -0,0 +1,230 @@
# Render with pinned official source and persist date/count history in Pages.
# The workflow deploys Pages artifacts only; it never creates repository commits.
name: Render Star History
on:
schedule:
- cron: "17 3 * * 1"
workflow_dispatch:
permissions:
contents: read
deployments: write
pages: write
id-token: write
concurrency:
group: star-history-pages
cancel-in-progress: true
env:
STAR_HISTORY_REPOSITORIES: marseventh/cloudflare-imgbed,marseventh/sanyue-imghub
STAR_HISTORY_SOURCE_REF: bcddc9d532b10bac7e0187a741288bf9cab17616
STAR_HISTORY_SOURCE_DIR: .star-history-upstream
STAR_HISTORY_SITE_DIR: .star-history-site
STAR_HISTORY_PAGES_URL: https://marseventh.github.io/CloudFlare-ImgBed
jobs:
render-and-deploy:
name: Render with official source and deploy to Pages
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out this repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Check out official Star History source
uses: actions/checkout@v7
with:
repository: star-history/star-history
ref: ${{ env.STAR_HISTORY_SOURCE_REF }}
path: ${{ env.STAR_HISTORY_SOURCE_DIR }}
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@v6
with:
version: 9.15.9
run_install: false
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: |
.star-history-upstream/pnpm-lock.yaml
.star-history-upstream/backend/pnpm-lock.yaml
- name: Install official renderer dependencies
shell: bash
run: |
set -euo pipefail
(cd "${STAR_HISTORY_SOURCE_DIR}" && pnpm install --frozen-lockfile)
(cd "${STAR_HISTORY_SOURCE_DIR}/backend" && pnpm install --frozen-lockfile)
- name: Refresh data and render official light/dark charts locally
id: render
shell: bash
env:
# One repository-metadata request per configured repository reads the
# current totals. Individual
# stargazers and the hosted Star History chart service are not queried.
GITHUB_JOB_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [[ -z "${GITHUB_JOB_TOKEN}" ]]; then
echo "::error::The workflow GITHUB_TOKEN is unavailable."
exit 1
fi
data_file="${RUNNER_TEMP}/star-history-data.json"
server_log="${RUNNER_TEMP}/star-history-backend.log"
cleanup() {
if [[ -n "${server_pid:-}" ]]; then
kill "${server_pid}" 2>/dev/null || true
wait "${server_pid}" 2>/dev/null || true
fi
}
trap cleanup EXIT
# Pages JSON is the runtime source of truth. The committed bootstrap
# seed is used only when the first deployment path returns HTTP 404.
python3 .github/star-history/star_history.py refresh-data \
--repository "${STAR_HISTORY_REPOSITORIES}" \
--seed-file .github/star-history/history.json \
--deployed-url "${STAR_HISTORY_PAGES_URL}/star-history-data.json" \
--output "${data_file}" \
--token-env GITHUB_JOB_TOKEN
# Seed the pinned official backend cache while leaving its JSDOM,
# XYChart, xkcd styling, theme handling, and SVGO renderer unchanged.
python3 .github/star-history/star_history.py patch-upstream \
--source-dir "${STAR_HISTORY_SOURCE_DIR}"
(cd "${STAR_HISTORY_SOURCE_DIR}/backend" && pnpm run build)
(
cd "${STAR_HISTORY_SOURCE_DIR}/backend"
STAR_HISTORY_DATA_PATH="${data_file}" pnpm exec tsx main.ts
) >"${server_log}" 2>&1 &
server_pid=$!
for attempt in {1..60}; do
if curl --fail --silent http://127.0.0.1:8080/healthz >/dev/null; then
break
fi
if ! kill -0 "${server_pid}" 2>/dev/null; then
cat "${server_log}" >&2
exit 1
fi
if [[ "${attempt}" == 60 ]]; then
cat "${server_log}" >&2
exit 1
fi
sleep 1
done
mkdir -p "${STAR_HISTORY_SITE_DIR}"
cp "${data_file}" "${STAR_HISTORY_SITE_DIR}/star-history-data.json"
if ! python3 .github/star-history/star_history.py render \
--repository "${STAR_HISTORY_REPOSITORIES}" \
--output-dir "${STAR_HISTORY_SITE_DIR}" \
--fallback-base-url "${STAR_HISTORY_PAGES_URL}"; then
cat "${server_log}" >&2
exit 1
fi
touch "${STAR_HISTORY_SITE_DIR}/.nojekyll"
(
cd "${STAR_HISTORY_SITE_DIR}"
sha256sum \
star-history-data.json \
star-history-dark.svg \
star-history-light.svg | LC_ALL=C sort > manifest.sha256
)
# A byte-stable same-day count skips the Pages deployment entirely.
current_dir="$(mktemp -d)"
changed=true
if curl --fail --silent --location \
"${STAR_HISTORY_PAGES_URL}/manifest.sha256" \
--output "${current_dir}/manifest.sha256" && \
cmp --silent \
"${STAR_HISTORY_SITE_DIR}/manifest.sha256" \
"${current_dir}/manifest.sha256"; then
changed=false
echo "The deployed Pages site is already current."
fi
echo "changed=${changed}" >> "${GITHUB_OUTPUT}"
- name: Configure GitHub Pages
if: steps.render.outputs.changed == 'true'
uses: actions/configure-pages@v6
- name: Upload Pages artifact
if: steps.render.outputs.changed == 'true'
uses: actions/upload-pages-artifact@v5
with:
path: ${{ env.STAR_HISTORY_SITE_DIR }}
- name: Deploy Pages artifact
if: steps.render.outputs.changed == 'true'
id: deployment
uses: actions/deploy-pages@v5
# Deployment records are not Git commits. Keep only the newest record so
# the repository's Deployments list does not grow every scheduled run.
- name: Delete old GitHub Pages deployment records
if: ${{ success() && steps.render.outcome == 'success' }}
uses: actions/github-script@v9
with:
github-token: ${{ github.token }}
script: |
const { owner, repo } = context.repo;
const environment = "github-pages";
const deployments = await github.paginate(
github.rest.repos.listDeployments,
{ owner, repo, environment, per_page: 100 },
);
deployments.sort((left, right) => {
const difference = Date.parse(right.created_at) - Date.parse(left.created_at);
return difference || right.id - left.id;
});
if (deployments.length <= 1) {
core.info(`Keeping ${deployments.length} ${environment} deployment record(s).`);
return;
}
const [currentDeployment, ...oldDeployments] = deployments;
for (const oldDeployment of oldDeployments) {
const { data: statuses } = await github.rest.repos.listDeploymentStatuses({
owner,
repo,
deployment_id: oldDeployment.id,
per_page: 1,
});
if (statuses[0]?.state !== "inactive") {
await github.rest.repos.createDeploymentStatus({
owner,
repo,
deployment_id: oldDeployment.id,
state: "inactive",
environment,
auto_inactive: false,
description: `Superseded by deployment ${currentDeployment.id}.`,
});
}
await github.rest.repos.deleteDeployment({
owner,
repo,
deployment_id: oldDeployment.id,
});
}
+4 -4
View File
@@ -167,11 +167,11 @@ Thank you to everyone who has contributed code, documentation, ideas, and feedba
**If you find the project useful, please consider giving it a Star ⭐. Thank you for your support!**
<a href="https://www.star-history.com/?repos=MarSeventh%2FCloudFlare-ImgBed%2CMarSeventh%2FSanyue-ImgHub&type=date&legend=top-left">
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&theme=dark&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<source media="(prefers-color-scheme: dark)" srcset="https://marseventh.github.io/CloudFlare-ImgBed/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://marseventh.github.io/CloudFlare-ImgBed/star-history-light.svg" />
<img alt="CloudFlare-ImgBed and Sanyue-ImgHub Star History Chart" src="https://marseventh.github.io/CloudFlare-ImgBed/star-history-light.svg" />
</picture>
</a>
+4 -4
View File
@@ -167,11 +167,11 @@ CloudFlare ImgBed 是支持 Docker 与 Serverless 部署的自建图床和文件
**如果这个项目对您有所帮助,欢迎点亮一个 Star ⭐,感谢您的支持!**
<a href="https://www.star-history.com/?repos=MarSeventh%2FCloudFlare-ImgBed%2CMarSeventh%2FSanyue-ImgHub&type=date&legend=top-left">
<a href="https://github.com/MarSeventh/CloudFlare-ImgBed">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&theme=dark&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=MarSeventh/CloudFlare-ImgBed%2CMarSeventh/Sanyue-ImgHub&type=date&legend=top-left&sealed_token=sAw_e7kRryMASKC9b3AqORk8leSZgKYTuCvYqOzqsyOmTse-00LgwOS4FtG75lHuCuxsyd-TPlyV3BieLloGaM-3M2AlLeQt2g1_Kczjm0UZdqnvVKRCR2J9oqdE0_XEKFMmOMLG_Loz8Bz3-JPKwiMyTjKM0LRRLm2TjGA73QSrTuOsRAqwj6F7LAVf" />
<source media="(prefers-color-scheme: dark)" srcset="https://marseventh.github.io/CloudFlare-ImgBed/star-history-dark.svg" />
<source media="(prefers-color-scheme: light)" srcset="https://marseventh.github.io/CloudFlare-ImgBed/star-history-light.svg" />
<img alt="CloudFlare-ImgBed 和 Sanyue-ImgHub Star 趋势图" src="https://marseventh.github.io/CloudFlare-ImgBed/star-history-light.svg" />
</picture>
</a>