Skip to content

Commit dce9489

Browse files
committed
chore: self-render star-history chart hourly, drop star-history.com embed
1 parent e8e433e commit dce9489

5 files changed

Lines changed: 279 additions & 4 deletions

File tree

.github/workflows/star-history.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: star history
2+
3+
# star-history.com caches repo data for 24h, which makes the README chart lag
4+
# a full day behind during a star spike. This renders the chart ourselves from
5+
# live stargazer data (tools/star_history.py) and commits the SVGs, so the
6+
# README stays at most an hour behind. The script's output is deterministic
7+
# per star count, so no-change hours produce no commit.
8+
on:
9+
schedule:
10+
- cron: "17 * * * *" # hourly, off the :00 rush
11+
workflow_dispatch:
12+
13+
permissions:
14+
contents: write
15+
16+
concurrency:
17+
group: star-history
18+
cancel-in-progress: true
19+
20+
jobs:
21+
render:
22+
name: render star chart
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v4
26+
- name: Render SVGs
27+
run: python3 tools/star_history.py tiliondev/fortress docs/assets
28+
env:
29+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30+
- name: Commit if changed
31+
run: |
32+
if git diff --quiet -- docs/assets/star-history-dark.svg docs/assets/star-history-light.svg; then
33+
echo "chart unchanged, nothing to commit"
34+
exit 0
35+
fi
36+
git config user.name "github-actions[bot]"
37+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
38+
git add docs/assets/star-history-dark.svg docs/assets/star-history-light.svg
39+
git commit -m "chore: refresh star-history chart"
40+
git push

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,11 +586,11 @@ BSD-3-Clause for the Fortress patches and tooling (matching Chromium). Chromium
586586

587587
Detection keeps moving, so a stealth engine is only as good as its last rebase. Fortress tracks the latest Chromium monthly, re-runs the full gauntlet, and ships a patch whenever a detector finds a new tell, so what you run keeps matching what a real Chrome install looks like. [Watch the releases](https://github.com/tiliondev/fortress/releases) to follow the `v2` MaskConfig work, or [star the repo](https://github.com/tiliondev/fortress/stargazers) if it's useful to you.
588588

589-
<a href="https://www.star-history.com/?repos=tiliondev%2Ffortress&type=date&legend=top-left">
589+
<a href="https://github.com/tiliondev/fortress/stargazers">
590590
<picture>
591-
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=tiliondev/fortress&type=date&theme=dark&legend=top-left&sealed_token=e4IK8Ee7-MzuWvz-1cXd9E6RFElN7jjE-0BHFC9ps01Qx2M9gXJbkqzSvbWEbTxOOwQPxEQ_XUDUbuhI_iT8eljoTFvpnaRqzjP_HTZi3lQjVc9XXOeVUg" />
592-
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=tiliondev/fortress&type=date&legend=top-left&sealed_token=e4IK8Ee7-MzuWvz-1cXd9E6RFElN7jjE-0BHFC9ps01Qx2M9gXJbkqzSvbWEbTxOOwQPxEQ_XUDUbuhI_iT8eljoTFvpnaRqzjP_HTZi3lQjVc9XXOeVUg" />
593-
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=tiliondev/fortress&type=date&legend=top-left&sealed_token=e4IK8Ee7-MzuWvz-1cXd9E6RFElN7jjE-0BHFC9ps01Qx2M9gXJbkqzSvbWEbTxOOwQPxEQ_XUDUbuhI_iT8eljoTFvpnaRqzjP_HTZi3lQjVc9XXOeVUg" />
591+
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/star-history-dark.svg" />
592+
<source media="(prefers-color-scheme: light)" srcset="docs/assets/star-history-light.svg" />
593+
<img alt="Star history chart" src="docs/assets/star-history-light.svg" />
594594
</picture>
595595
</a>
596596

docs/assets/star-history-dark.svg

Lines changed: 20 additions & 0 deletions
Loading

docs/assets/star-history-light.svg

Lines changed: 20 additions & 0 deletions
Loading

tools/star_history.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env python3
2+
"""Render the README star-history chart from live stargazer data.
3+
4+
star-history.com caches repo data server-side for 24h, so its embed lags
5+
badly during a spike. This renders the same cumulative-stars curve straight
6+
from the GitHub API into docs/assets/star-history-{dark,light}.svg, which the
7+
README references directly. Run hourly by .github/workflows/star-history.yml.
8+
9+
Output is deterministic for a given star count (no timestamps, x-domain ends
10+
at the last star event, y-max rounded to a tick step), so the workflow's
11+
"commit only if changed" check stays quiet between new stars.
12+
13+
Stdlib only — no pip install on the runner.
14+
15+
Usage: GITHUB_TOKEN=... python3 tools/star_history.py [owner/repo] [outdir]
16+
"""
17+
18+
import json
19+
import os
20+
import sys
21+
import urllib.request
22+
from datetime import datetime, timezone
23+
24+
REPO = sys.argv[1] if len(sys.argv) > 1 else "tiliondev/fortress"
25+
OUTDIR = sys.argv[2] if len(sys.argv) > 2 else "docs/assets"
26+
27+
# The stargazers listing is capped at 400 pages (40k stars) by GitHub.
28+
PER_PAGE = 100
29+
MAX_PAGES = 400
30+
MAX_POINTS = 240 # downsample the curve beyond this; keeps SVGs small
31+
32+
W, H = 800, 420
33+
MARGIN = {"top": 44, "right": 40, "bottom": 52, "left": 60}
34+
35+
THEMES = {
36+
"dark": {
37+
"text": "#9198a1",
38+
"strong": "#e6edf3",
39+
"grid": "#30363d",
40+
"line": "#e3b341",
41+
"fill": "#e3b341",
42+
},
43+
"light": {
44+
"text": "#59636e",
45+
"strong": "#1f2328",
46+
"grid": "#d1d9e0",
47+
"line": "#9a6700",
48+
"fill": "#bf8700",
49+
},
50+
}
51+
52+
MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
53+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
54+
55+
56+
def fetch_star_times(repo, token):
57+
"""Return sorted list of starred_at datetimes for every stargazer."""
58+
times = []
59+
for page in range(1, MAX_PAGES + 1):
60+
req = urllib.request.Request(
61+
f"https://api.github.com/repos/{repo}/stargazers"
62+
f"?per_page={PER_PAGE}&page={page}",
63+
headers={
64+
# star+json includes starred_at timestamps
65+
"Accept": "application/vnd.github.star+json",
66+
"Authorization": f"Bearer {token}",
67+
"X-GitHub-Api-Version": "2022-11-28",
68+
},
69+
)
70+
with urllib.request.urlopen(req, timeout=30) as resp:
71+
batch = json.load(resp)
72+
if not batch:
73+
break
74+
times += [
75+
datetime.strptime(s["starred_at"], "%Y-%m-%dT%H:%M:%SZ")
76+
.replace(tzinfo=timezone.utc)
77+
for s in batch
78+
]
79+
if len(batch) < PER_PAGE:
80+
break
81+
times.sort()
82+
return times
83+
84+
85+
def nice_step(target):
86+
"""Smallest 1/2/5 x 10^k step >= target, for clean y-axis ticks."""
87+
if target <= 1:
88+
return 1
89+
mag = 1
90+
while True:
91+
for m in (1, 2, 5):
92+
if m * mag >= target:
93+
return m * mag
94+
mag *= 10
95+
96+
97+
def build_series(times):
98+
"""(datetime, cumulative_count) points, downsampled but keeping endpoints."""
99+
pts = [(t, i + 1) for i, t in enumerate(times)]
100+
if len(pts) > MAX_POINTS:
101+
idx = {round(i * (len(pts) - 1) / (MAX_POINTS - 1)) for i in range(MAX_POINTS)}
102+
pts = [pts[i] for i in sorted(idx)]
103+
return pts
104+
105+
106+
def fmt_date(dt, span_days):
107+
if span_days > 300:
108+
return f"{MONTHS[dt.month - 1]} {dt.year}"
109+
return f"{MONTHS[dt.month - 1]} {dt.day}"
110+
111+
112+
def render(pts, theme, repo):
113+
c = THEMES[theme]
114+
x0, x1 = pts[0][0].timestamp(), pts[-1][0].timestamp()
115+
if x1 == x0:
116+
x1 = x0 + 1
117+
total = pts[-1][1]
118+
step = nice_step(total / 4)
119+
ymax = max(step, ((total + step - 1) // step) * step)
120+
121+
px0, px1 = MARGIN["left"], W - MARGIN["right"]
122+
py0, py1 = H - MARGIN["bottom"], MARGIN["top"]
123+
124+
def X(t):
125+
return px0 + (t.timestamp() - x0) / (x1 - x0) * (px1 - px0)
126+
127+
def Y(n):
128+
return py0 - n / ymax * (py0 - py1)
129+
130+
span_days = (x1 - x0) / 86400
131+
path = " ".join(
132+
f"{'M' if i == 0 else 'L'}{X(t):.1f} {Y(n):.1f}" for i, (t, n) in enumerate(pts)
133+
)
134+
area = f"{path} L{X(pts[-1][0]):.1f} {py0} L{X(pts[0][0]):.1f} {py0} Z"
135+
136+
parts = [
137+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" '
138+
f'viewBox="0 0 {W} {H}" font-family="Helvetica,Arial,sans-serif">',
139+
f'<text x="{px0}" y="26" font-size="15" font-weight="600" '
140+
f'fill="{c["strong"]}">{repo} — GitHub stars</text>',
141+
]
142+
143+
for n in range(0, ymax + 1, step):
144+
y = Y(n)
145+
parts.append(
146+
f'<line x1="{px0}" y1="{y:.1f}" x2="{px1}" y2="{y:.1f}" '
147+
f'stroke="{c["grid"]}" stroke-width="1"/>'
148+
)
149+
parts.append(
150+
f'<text x="{px0 - 8}" y="{y + 4:.1f}" font-size="12" '
151+
f'text-anchor="end" fill="{c["text"]}">{n}</text>'
152+
)
153+
154+
n_xticks = 5
155+
for i in range(n_xticks):
156+
ts = x0 + (x1 - x0) * i / (n_xticks - 1)
157+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
158+
anchor = "start" if i == 0 else "end" if i == n_xticks - 1 else "middle"
159+
parts.append(
160+
f'<text x="{px0 + (px1 - px0) * i / (n_xticks - 1):.1f}" y="{py0 + 22}" '
161+
f'font-size="12" text-anchor="{anchor}" fill="{c["text"]}">'
162+
f"{fmt_date(dt, span_days)}</text>"
163+
)
164+
165+
lx, ly = X(pts[-1][0]), Y(total)
166+
parts += [
167+
f'<path d="{area}" fill="{c["fill"]}" opacity="0.12"/>',
168+
f'<path d="{path}" fill="none" stroke="{c["line"]}" '
169+
f'stroke-width="2.5" stroke-linejoin="round"/>',
170+
f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="4" fill="{c["line"]}"/>',
171+
f'<text x="{lx - 8:.1f}" y="{ly - 10:.1f}" font-size="13" font-weight="600" '
172+
f'text-anchor="end" fill="{c["strong"]}">{total}</text>',
173+
"</svg>",
174+
]
175+
return "\n".join(parts) + "\n"
176+
177+
178+
def main():
179+
token = os.environ.get("GITHUB_TOKEN")
180+
if not token:
181+
sys.exit("GITHUB_TOKEN is required (stargazer pagination needs auth)")
182+
times = fetch_star_times(REPO, token)
183+
if not times:
184+
sys.exit(f"no stargazers returned for {REPO}")
185+
pts = build_series(times)
186+
os.makedirs(OUTDIR, exist_ok=True)
187+
for theme in THEMES:
188+
out = os.path.join(OUTDIR, f"star-history-{theme}.svg")
189+
with open(out, "w") as f:
190+
f.write(render(pts, theme, REPO))
191+
print(f"wrote {out} ({pts[-1][1]} stars)")
192+
193+
194+
if __name__ == "__main__":
195+
main()

0 commit comments

Comments
 (0)