From 8d8baf537436300ee4adc92798e4371696053c3b Mon Sep 17 00:00:00 2001
From: Andrea Arturo Venti Fuentes <117413846+av1155@users.noreply.github.com>
Date: Thu, 6 Aug 2026 14:06:52 -0400
Subject: [PATCH 1/2] fix(docs): self-host the star history chart
The README embed pointed at api.star-history.com with a sealed token.
That endpoint now returns 403 for this repo: GitHub restricted the
stargazers API to a repository's admins and collaborators in June 2026,
and the wrapped credential no longer clears that bar.
Regenerating the token would work, but the permission GitHub accepts is
Contents: Read and write. That is a credential which can push to this
repo, decrypted by a third party on every README view, while any v* tag
publishes ghcr.io latest. Render the chart from a scheduled workflow
instead: the per-run Actions token holds the same permission but expires
with the job and never leaves GitHub.
Charts are committed to the orphan `assets` branch because `main`
requires pull requests and a daily refresh is not worth a daily PR.
---
.github/workflows/star-history.yml | 72 +++++++++
README.md | 12 +-
lychee.toml | 4 -
scripts/star_history.py | 240 +++++++++++++++++++++++++++++
4 files changed, 317 insertions(+), 11 deletions(-)
create mode 100644 .github/workflows/star-history.yml
create mode 100644 scripts/star_history.py
diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml
new file mode 100644
index 00000000..2104c42f
--- /dev/null
+++ b/.github/workflows/star-history.yml
@@ -0,0 +1,72 @@
+name: Star History
+
+on:
+ schedule:
+ # Daily at 07:23 UTC. Off-the-hour on purpose: GitHub queues scheduled
+ # workflows hardest at the top of the hour, so an odd minute starts sooner.
+ - cron: "23 7 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: ${{ github.workflow }}
+ cancel-in-progress: false
+
+jobs:
+ refresh:
+ name: Refresh star history chart
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ # The `assets` branch is orphaned from main: it carries only the rendered
+ # charts, so the README can point at raw.githubusercontent.com without
+ # this job needing to push to protected `main`.
+ - name: Checkout assets branch
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: assets
+ path: assets-branch
+
+ # GitHub restricted this endpoint to the repo's admins and collaborators
+ # in June 2026. The per-run Actions token clears that bar and expires with
+ # the job, which is why the chart is rendered here rather than embedded
+ # from a service that would need a long-lived token in the README.
+ - name: Fetch stargazer timestamps
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh api --paginate \
+ -H "Accept: application/vnd.github.star+json" \
+ "repos/${GITHUB_REPOSITORY}/stargazers?per_page=100" \
+ --jq '.[].starred_at' > stars.txt
+ echo "Fetched $(wc -l < stars.txt) stargazer timestamps."
+
+ # Stock runner Python is enough: the renderer is stdlib-only, so
+ # provisioning the project virtualenv would be cost without benefit.
+ - name: Render charts
+ run: |
+ python3 scripts/star_history.py \
+ --repo "${GITHUB_REPOSITORY}" \
+ --out-dir chart < stars.txt
+
+ - name: Publish to assets branch
+ working-directory: assets-branch
+ run: |
+ cp ../chart/star-history-light.svg ../chart/star-history-dark.svg .
+ git add star-history-light.svg star-history-dark.svg
+ # Identical input renders byte-identical output, so an unchanged star
+ # count is a no-op rather than a daily empty commit.
+ if git diff --staged --quiet; then
+ echo "Chart unchanged; nothing to publish."
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "chore: refresh star history chart"
+ git push
diff --git a/README.md b/README.md
index ab0494c4..149d9319 100644
--- a/README.md
+++ b/README.md
@@ -172,13 +172,11 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, coding standard
diff --git a/lychee.toml b/lychee.toml
index 0c5c3dc5..86fc60a6 100644
--- a/lychee.toml
+++ b/lychee.toml
@@ -36,8 +36,4 @@ exclude = [
"^https?://authentik-proxy([:./]|$)",
"^https?://[^/]*\\.example\\.com",
"^https?://your-host",
- # Chart-rendering service used by the README star-history embed.
- # Intermittently returns 5xx under load (star-history/star-history#546),
- # so its availability is not a signal about our docs.
- "^https?://api\\.star-history\\.com/",
]
diff --git a/scripts/star_history.py b/scripts/star_history.py
new file mode 100644
index 00000000..248b7ab9
--- /dev/null
+++ b/scripts/star_history.py
@@ -0,0 +1,240 @@
+"""Render the repository's star history as self-contained light and dark SVGs.
+
+Reads ISO-8601 ``starred_at`` timestamps on stdin, one per line, and writes a
+cumulative star-count chart in both themes. The README embeds the pair through
+a ```` block so each colour scheme gets readable contrast.
+
+Rendering locally rather than embedding a hosted chart service is a security
+decision, not a stylistic one. GitHub restricted the stargazers endpoint to a
+repository's admins and collaborators in June 2026, and the permission that
+satisfies that check is ``Contents: Read and write``. Any third-party embed
+that reads this data on a visitor's behalf therefore needs a credential that
+can also push to this repo, which is not something to park in a public README.
+The scheduled workflow feeding this script uses the per-run Actions token
+instead, which expires with the job.
+
+Deliberately dependency-free so the workflow runs on the runner's stock Python
+without provisioning the project virtualenv for a docs-only job.
+"""
+
+from __future__ import annotations
+
+import argparse
+import html
+import math
+import sys
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+# Geometry in user units. The SVG scales to its container, so only the
+# relative proportions of these numbers matter.
+WIDTH = 800
+HEIGHT = 400
+PAD_LEFT = 64
+PAD_RIGHT = 28
+PAD_TOP = 52
+PAD_BOTTOM = 44
+
+Y_TICKS = 4
+X_TICKS = 5
+
+# Past this many points the polyline gains no visible detail and only costs
+# bytes, which matters because GitHub proxies README images through camo.
+MAX_POINTS = 320
+
+# Below this span, dated ticks stay unambiguous without a year; above it the
+# month alone starts repeating across years.
+DAY_TICK_LIMIT_DAYS = 730
+
+FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
+
+
+@dataclass(frozen=True, slots=True)
+class Theme:
+ """Colour set for one rendered variant."""
+
+ name: str
+ accent: str
+ grid: str
+ text: str
+ title: str
+
+
+# Cyan ramp taken from website/src/css/tokens.css (brand-700 light, brand-400
+# dark) so the chart reads as part of the same product as the docs site.
+THEMES = (
+ Theme(name="light", accent="#0e7490", grid="#d8dee4", text="#57606a", title="#1f2328"),
+ Theme(name="dark", accent="#22d3ee", grid="#30363d", text="#8b949e", title="#e6edf3"),
+)
+
+Point = tuple[datetime, int]
+
+
+def parse_timestamps(lines: Iterable[str]) -> list[datetime]:
+ """Parse one ISO-8601 timestamp per line, ignoring blanks, sorted ascending."""
+ stamps = [datetime.fromisoformat(text) for line in lines if (text := line.strip())]
+ stamps.sort()
+ return stamps
+
+
+def build_series(stamps: Sequence[datetime]) -> list[Point]:
+ """Pair each star with the running total at the moment it was given."""
+ return [(stamp, index) for index, stamp in enumerate(stamps, start=1)]
+
+
+def downsample(series: Sequence[Point], limit: int = MAX_POINTS) -> list[Point]:
+ """Thin the series to at most ``limit`` evenly spaced points."""
+ if len(series) <= limit:
+ return list(series)
+
+ step = (len(series) - 1) / (limit - 1)
+ picked = [series[round(index * step)] for index in range(limit)]
+ # The final point carries the headline count, so rounding must never drop it.
+ picked[-1] = series[-1]
+ return picked
+
+
+def nice_step(span: float, ticks: int) -> float:
+ """Round a raw axis interval up to a human-readable 1/2/2.5/5 x 10^n step."""
+ if span <= 0:
+ return 1.0
+
+ raw = span / ticks
+ magnitude = 10 ** math.floor(math.log10(raw))
+ for factor in (1, 2, 2.5, 5):
+ if raw <= factor * magnitude:
+ return factor * magnitude
+ return 10 * magnitude
+
+
+def _scale_x(stamp: datetime, first: datetime, last: datetime) -> float:
+ span = (last - first).total_seconds()
+ usable = WIDTH - PAD_LEFT - PAD_RIGHT
+ if span <= 0:
+ return PAD_LEFT + usable
+ return PAD_LEFT + (stamp - first).total_seconds() / span * usable
+
+
+def _scale_y(count: float, top: float) -> float:
+ usable = HEIGHT - PAD_TOP - PAD_BOTTOM
+ if top <= 0:
+ return HEIGHT - PAD_BOTTOM
+ return HEIGHT - PAD_BOTTOM - count / top * usable
+
+
+def _empty_svg(repo: str, theme: Theme) -> str:
+ """Placeholder so a starless repo still yields a valid, embeddable file."""
+ label = html.escape(repo)
+ return (
+ f'"
+ )
+
+
+def render(series: Sequence[Point], repo: str, theme: Theme) -> str:
+ """Build one complete SVG document for the given series and theme."""
+ if not series:
+ return _empty_svg(repo, theme)
+
+ points = downsample(series)
+ first, last = points[0][0], points[-1][0]
+ total = series[-1][1]
+
+ step = nice_step(total, Y_TICKS)
+ top = math.ceil(total / step) * step
+
+ parts: list[str] = []
+ label = html.escape(repo)
+ span_days = (last - first).days
+ tick_format = "%b %d" if span_days <= DAY_TICK_LIMIT_DAYS else "%b %Y"
+ window = f"{first.strftime('%b %Y')} to {last.strftime('%b %Y')}"
+
+ parts.append(
+ f'")
+ return "".join(parts)
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Render star history SVGs from stdin timestamps.")
+ parser.add_argument("--repo", required=True, help="owner/name, shown as the chart title")
+ parser.add_argument(
+ "--out-dir", type=Path, default=Path(), help="directory to write the SVGs into"
+ )
+ args = parser.parse_args()
+
+ series = build_series(parse_timestamps(sys.stdin))
+ args.out_dir.mkdir(parents=True, exist_ok=True)
+
+ for theme in THEMES:
+ target = args.out_dir / f"star-history-{theme.name}.svg"
+ target.write_text(render(series, args.repo, theme), encoding="utf-8")
+ print(f"wrote {target} ({len(series)} stars)")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 0cf92d5537e682f7cf3531d308a2ef6db7268a45 Mon Sep 17 00:00:00 2001
From: Andrea Arturo Venti Fuentes <117413846+av1155@users.noreply.github.com>
Date: Thu, 6 Aug 2026 14:46:03 -0400
Subject: [PATCH 2/2] fix(docs): harden star history chart pipeline
Review of the initial commit surfaced four renderer defects and two
workflow gaps.
Renderer: the y axis produced fractional gridlines at low counts (10
stars rendered 0/2.5/5/7.5/10), day-and-month x ticks repeated the same
label once the window crossed a year (Dec 31 twice at a 729 day span),
downsample divided by zero at a limit below two, and the tick format
switched to scientific notation past a million. nice_step is now floored
at 1 with integer factors, the axis falls back to month-and-year when
the window spans two calendar years, and mypy strict passes on the file.
Workflow: a 200 carrying an empty or truncated array is a success to gh,
so set -e let it through and the renderer published a "No stars yet"
placeholder over a good chart, unattended, on a public README. The fetch
step now refuses to publish on zero rows or on a count that has halved
against the published chart. Runs are pinned to this repository so a
fork dispatch cannot fail on the missing assets branch, and the publish
step amends rather than appends so the orphan branch stays one commit
instead of accruing megabytes of unread history.
Adds tests covering the pure functions; all four renderer defects fail
against the previous revision.
---
.github/workflows/star-history.yml | 40 +++++++-
scripts/star_history.py | 29 +++---
tests/test_star_history.py | 152 +++++++++++++++++++++++++++++
3 files changed, 204 insertions(+), 17 deletions(-)
create mode 100644 tests/test_star_history.py
diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml
index 2104c42f..8300efa6 100644
--- a/.github/workflows/star-history.yml
+++ b/.github/workflows/star-history.yml
@@ -17,6 +17,9 @@ concurrency:
jobs:
refresh:
name: Refresh star history chart
+ # Forks have no `assets` branch, so a dispatched run there would fail on
+ # the checkout below.
+ if: github.repository == 'av1155/houndarr'
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -41,11 +44,33 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
+ set -euo pipefail
gh api --paginate \
-H "Accept: application/vnd.github.star+json" \
"repos/${GITHUB_REPOSITORY}/stargazers?per_page=100" \
--jq '.[].starred_at' > stars.txt
- echo "Fetched $(wc -l < stars.txt) stargazer timestamps."
+ COUNT=$(wc -l < stars.txt)
+ echo "Fetched ${COUNT} stargazer timestamps."
+
+ # A 200 carrying an empty or short array is a success as far as gh is
+ # concerned, so `set -e` will not catch it. Rendering one would
+ # publish a "No stars yet" placeholder over a good chart, on a public
+ # README, unattended. The already-published chart records its own
+ # count in the aria-label, which makes a usable floor: real unstarring
+ # is gradual, so losing half the stars in a day means the fetch was
+ # truncated rather than the stars being gone.
+ PREVIOUS=$(sed -n 's/.*: \([0-9]\{1,\}\) stars,.*/\1/p' \
+ assets-branch/star-history-dark.svg)
+ echo "Previously published: ${PREVIOUS:-unknown}"
+
+ if [ "${COUNT}" -eq 0 ]; then
+ echo "::error::Stargazer fetch returned no rows; refusing to publish."
+ exit 1
+ fi
+ if [ -n "${PREVIOUS}" ] && [ "${COUNT}" -lt $((PREVIOUS / 2)) ]; then
+ echo "::error::Fetched ${COUNT} rows against ${PREVIOUS} published; refusing to publish."
+ exit 1
+ fi
# Stock runner Python is enough: the renderer is stdlib-only, so
# provisioning the project virtualenv would be cost without benefit.
@@ -58,15 +83,20 @@ jobs:
- name: Publish to assets branch
working-directory: assets-branch
run: |
+ set -euo pipefail
cp ../chart/star-history-light.svg ../chart/star-history-dark.svg .
git add star-history-light.svg star-history-dark.svg
- # Identical input renders byte-identical output, so an unchanged star
- # count is a no-op rather than a daily empty commit.
+ # Identical input renders byte-identical SVGs, so a day with no chart
+ # movement is a no-op rather than an empty commit.
if git diff --staged --quiet; then
echo "Chart unchanged; nothing to publish."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git commit -m "chore: refresh star history chart"
- git push
+ # Amend rather than append: only the newest chart is ever read, so
+ # accumulating a commit per day would add megabytes of dead history
+ # to every clone. The force-push is scoped to this orphan branch and
+ # can never reach main.
+ git commit --amend -m "chore: refresh star history chart"
+ git push --force-with-lease origin HEAD:assets
diff --git a/scripts/star_history.py b/scripts/star_history.py
index 248b7ab9..34912409 100644
--- a/scripts/star_history.py
+++ b/scripts/star_history.py
@@ -44,10 +44,6 @@
# bytes, which matters because GitHub proxies README images through camo.
MAX_POINTS = 320
-# Below this span, dated ticks stay unambiguous without a year; above it the
-# month alone starts repeating across years.
-DAY_TICK_LIMIT_DAYS = 730
-
FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
@@ -86,6 +82,10 @@ def build_series(stamps: Sequence[datetime]) -> list[Point]:
def downsample(series: Sequence[Point], limit: int = MAX_POINTS) -> list[Point]:
"""Thin the series to at most ``limit`` evenly spaced points."""
+ # Two points are the minimum a line needs, and the interpolation below
+ # divides by limit - 1.
+ if limit < 2:
+ return [series[-1]] if series else []
if len(series) <= limit:
return list(series)
@@ -97,16 +97,20 @@ def downsample(series: Sequence[Point], limit: int = MAX_POINTS) -> list[Point]:
def nice_step(span: float, ticks: int) -> float:
- """Round a raw axis interval up to a human-readable 1/2/2.5/5 x 10^n step."""
+ """Round a raw axis interval up to a human-readable 1/2/5 x 10^n step.
+
+ Floored at 1 and restricted to integer factors because this axis counts
+ stars: a gridline at 2.5 stars would be meaningless.
+ """
if span <= 0:
return 1.0
raw = span / ticks
- magnitude = 10 ** math.floor(math.log10(raw))
- for factor in (1, 2, 2.5, 5):
+ magnitude: float = 10 ** math.floor(math.log10(raw))
+ for factor in (1, 2, 5):
if raw <= factor * magnitude:
- return factor * magnitude
- return 10 * magnitude
+ return max(1.0, factor * magnitude)
+ return max(1.0, 10 * magnitude)
def _scale_x(stamp: datetime, first: datetime, last: datetime) -> float:
@@ -150,8 +154,9 @@ def render(series: Sequence[Point], repo: str, theme: Theme) -> str:
parts: list[str] = []
label = html.escape(repo)
- span_days = (last - first).days
- tick_format = "%b %d" if span_days <= DAY_TICK_LIMIT_DAYS else "%b %Y"
+ # A day-and-month tick repeats once the window crosses a year boundary,
+ # which would print the same label twice on the axis.
+ tick_format = "%b %d" if first.year == last.year else "%b %Y"
window = f"{first.strftime('%b %Y')} to {last.strftime('%b %Y')}"
parts.append(
@@ -186,7 +191,7 @@ def render(series: Sequence[Point], repo: str, theme: Theme) -> str:
)
parts.append(
f'{tick:g}'
+ f'font-size="12" fill="{theme.text}">{tick:,.0f}'
)
tick += step
diff --git a/tests/test_star_history.py b/tests/test_star_history.py
new file mode 100644
index 00000000..26c2009f
--- /dev/null
+++ b/tests/test_star_history.py
@@ -0,0 +1,152 @@
+"""Unit tests for the README star-history chart renderer.
+
+The renderer runs unattended on a schedule and writes straight to a public
+README, so the cheap invariants (integer star ticks, unique axis labels,
+well-formed XML on degenerate input) are worth pinning here rather than
+discovering them on the chart itself.
+
+`scripts/` is outside the package, so the module is loaded by path using the
+same repo-root discovery as `tests/test_gates/test_bootstrap_wiring_gate.py`.
+"""
+
+import importlib.util
+import sys
+import xml.etree.ElementTree as ET
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+
+import houndarr
+
+_REPO_ROOT = Path(houndarr.__file__).resolve().parents[2]
+_SCRIPT = _REPO_ROOT / "scripts" / "star_history.py"
+
+_spec = importlib.util.spec_from_file_location("star_history", _SCRIPT)
+assert _spec is not None and _spec.loader is not None
+star_history = importlib.util.module_from_spec(_spec)
+# `@dataclass(slots=True)` resolves its own module from sys.modules while the
+# class body executes, so registration has to precede exec_module.
+sys.modules[_spec.name] = star_history
+_spec.loader.exec_module(star_history)
+
+BASE = datetime(2026, 1, 1, tzinfo=UTC)
+
+
+def _series(count, *, spacing_days=1, start=BASE):
+ return star_history.build_series(
+ [start + timedelta(days=i * spacing_days) for i in range(count)]
+ )
+
+
+def _series_spanning(days, *, count=200, start=BASE):
+ """A series whose first and last points are exactly `days` apart."""
+ step = timedelta(days=days) / (count - 1)
+ return star_history.build_series([start + i * step for i in range(count)])
+
+
+def _axis_labels(svg, axis):
+ """Pull one axis's tick labels.
+
+ Both axes render at the same font size, so they are told apart by the
+ coordinate every tick on that axis shares: y-ticks sit at a fixed x, and
+ x-ticks sit at a fixed y. These offsets mirror `render`.
+ """
+ root = ET.fromstring(svg)
+ ns = "{http://www.w3.org/2000/svg}"
+ shared = {
+ "y": ("x", str(star_history.PAD_LEFT - 12)),
+ "x": ("y", str(star_history.HEIGHT - star_history.PAD_BOTTOM + 22)),
+ }[axis]
+ attr, value = shared
+ return [el.text for el in root.iter(f"{ns}text") if el.get(attr) == value]
+
+
+class TestNiceStep:
+ """Star counts are integers, so the axis they sit on must be too."""
+
+ def test_step_is_never_fractional(self):
+ for total in range(1, 2000):
+ step = star_history.nice_step(total, star_history.Y_TICKS)
+ assert step >= 1.0, f"total={total} produced sub-unit step {step}"
+ assert step == int(step), f"total={total} produced fractional step {step}"
+
+ def test_degenerate_span_is_safe(self):
+ assert star_history.nice_step(0, 4) == 1.0
+ assert star_history.nice_step(-5, 4) == 1.0
+
+
+class TestDownsample:
+ def test_endpoints_survive(self):
+ series = _series(5000)
+ picked = star_history.downsample(series)
+ assert picked[0] == series[0]
+ assert picked[-1] == series[-1], "the final point carries the headline count"
+ assert len(picked) <= star_history.MAX_POINTS
+
+ def test_short_series_passes_through(self):
+ series = _series(10)
+ assert star_history.downsample(series) == series
+
+ def test_counts_stay_monotonic(self):
+ picked = star_history.downsample(_series(5000))
+ counts = [count for _, count in picked]
+ assert counts == sorted(counts)
+
+ def test_limit_below_two_does_not_divide_by_zero(self):
+ series = _series(10)
+ assert star_history.downsample(series, 1) == [series[-1]]
+ assert star_history.downsample([], 1) == []
+
+
+class TestRender:
+ """Every input must yield a parseable document; the workflow commits it blind."""
+
+ def test_degenerate_inputs_stay_well_formed(self):
+ theme = star_history.THEMES[0]
+ cases = {
+ "empty": [],
+ "single": _series(1),
+ "pair": _series(2),
+ "same instant": star_history.build_series([BASE, BASE, BASE]),
+ "large": _series(5000),
+ }
+ for name, series in cases.items():
+ svg = star_history.render(series, "av1155/houndarr", theme)
+ ET.fromstring(svg) # raises on malformed XML
+ assert svg.startswith("