Skip to content

Commit ee9a9a1

Browse files
committed
feat: add CI time refresh for star rating snapshots
1 parent 82c317a commit ee9a9a1

12 files changed

Lines changed: 274 additions & 122 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ jobs:
5353
- name: Run typecheck
5454
run: npm run typecheck
5555

56+
- name: Refresh GitHub star counts
57+
# Unauthenticated (60 req/hr/IP). The fetcher merges per repo and keeps
58+
# the committed snapshot on failure, so a rate-limited deploy still
59+
# ships the last known counts instead of failing the build.
60+
run: node ../scripts/fetch-github-stars.js
61+
5662
- name: Build website
5763
run: npm run build
5864

‎scripts/fetch-github-stars.js‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#!/usr/bin/env node
2+
// Refreshes the committed star snapshot the site renders as its build-time
3+
// baseline (see github-stars.js). Properties a deploy depends on:
4+
// - merge is PER REPO: a repo that fails keeps its previous value, so a
5+
// rate-limited or offline runner still ships usable numbers;
6+
// - the process always exits 0 and only warns on failure, so GitHub being
7+
// unreachable can never fail a build;
8+
// - an unchanged snapshot is not rewritten, so a no-op refresh leaves the
9+
// working tree clean.
10+
11+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
12+
import { dirname } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
15+
import { PROJECTS, parseStars } from './github-stars.js';
16+
17+
const SNAPSHOT_PATH = fileURLToPath(
18+
new URL('../website/src/generated/github-stars.json', import.meta.url)
19+
);
20+
21+
async function readSnapshot() {
22+
try {
23+
return JSON.parse(await readFile(SNAPSHOT_PATH, 'utf8'));
24+
} catch {
25+
return { projects: [] };
26+
}
27+
}
28+
29+
async function fetchStars(repo) {
30+
try {
31+
const response = await fetch(`https://api.github.com/repos/${repo}`, {
32+
headers: { Accept: 'application/vnd.github+json' },
33+
signal: AbortSignal.timeout(10_000),
34+
});
35+
if (!response.ok) return { repo, stars: null, reason: `HTTP ${response.status}` };
36+
const stars = parseStars(await response.json());
37+
return stars === null
38+
? { repo, stars: null, reason: 'unexpected payload' }
39+
: { repo, stars };
40+
} catch (error) {
41+
return { repo, stars: null, reason: error.message };
42+
}
43+
}
44+
45+
// Per-repo merge: a failed repo keeps its previous entry — including its own
46+
// fetchedAt — so a timestamp always describes the number it sits next to. An
47+
// unchanged count is also left as-is, so a no-op refresh dirties nothing.
48+
function mergeProjects(previous, results) {
49+
const previousByRepo = new Map(previous.map((project) => [project.repo, project]));
50+
return results.flatMap(({ repo, stars }) => {
51+
const prior = previousByRepo.get(repo);
52+
if (stars === null) return prior ? [prior] : [];
53+
if (prior && prior.stars === stars) return [prior];
54+
const { name } = PROJECTS.find((project) => project.repo === repo);
55+
return [{ name, repo, stars, fetchedAt: new Date().toISOString() }];
56+
});
57+
}
58+
59+
// Two-space JSON with a trailing newline is exactly prettier's output, so the
60+
// generated file passes `format:check` without an ignore entry.
61+
function serialize(projects) {
62+
return `${JSON.stringify({ projects }, null, 2)}\n`;
63+
}
64+
65+
async function main() {
66+
const { projects: previous = [] } = await readSnapshot();
67+
const results = await Promise.all(PROJECTS.map(({ repo }) => fetchStars(repo)));
68+
for (const { repo, reason } of results.filter(({ stars }) => stars === null)) {
69+
console.warn(`[stars] ${repo}: ${reason} — keeping last known value`);
70+
}
71+
72+
const next = serialize(mergeProjects(previous, results));
73+
if (next === serialize(previous)) {
74+
console.log('[stars] snapshot unchanged');
75+
return;
76+
}
77+
await mkdir(dirname(SNAPSHOT_PATH), { recursive: true });
78+
await writeFile(SNAPSHOT_PATH, next, 'utf8');
79+
console.log(`[stars] wrote ${SNAPSHOT_PATH}`);
80+
}
81+
82+
main().catch((error) => {
83+
console.warn(`[stars] refresh failed: ${error.message} — keeping committed snapshot`);
84+
});

‎scripts/github-stars.js‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Canonical inputs for the build-time star snapshot: the open-source projects
2+
// shown in the homepage trust band. This list is the single source of truth for
3+
// repo slugs and display names — scripts/fetch-github-stars.js writes it into
4+
// website/src/generated/github-stars.json, which the site imports. Adding a
5+
// project means adding it here and to website/docs/intro.mdx;
6+
// GitHubSocialProof/stars.test.ts fails if those two lists diverge.
7+
8+
export const PROJECTS = [
9+
{ name: 'Book source', repo: 'agenticoding/agenticoding.github.io' },
10+
{ name: 'ChunkHound', repo: 'chunkhound/chunkhound' },
11+
{ name: 'Pi agenticoding extension', repo: 'agenticoding/pi-agenticoding' },
12+
];
13+
14+
// A star count is trustworthy only as a non-negative safe integer; the /repos
15+
// payload carries unrelated fields that must not be smuggled into the snapshot.
16+
export function parseStars(payload) {
17+
const stars = payload?.stargazers_count;
18+
return Number.isSafeInteger(stars) && stars >= 0 ? stars : null;
19+
}

‎scripts/test-responsive-diagrams.cjs‎

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const http = require("node:http");
44
const net = require("node:net");
55
const os = require("node:os");
66
const path = require("node:path");
7+
const { pathToFileURL } = require("node:url");
78

89
const puppeteer = require(
910
path.resolve(__dirname, "..", "website", "node_modules", "puppeteer"),
@@ -37,6 +38,16 @@ const FOUNDATIONS_FIRST_DOC = "/how-llms-work";
3738
const TRACE_DIR = path.join(path.resolve(__dirname, "..", "website"), "browser-contracts-traces");
3839

3940
const websiteDir = path.resolve(__dirname, "..", "website");
41+
// The build-time star snapshot and its canonical formatter, read by the
42+
// no-JavaScript homepage contract below.
43+
const STARS_SNAPSHOT = path.join(websiteDir, "src", "generated", "github-stars.json");
44+
const FORMAT_STARS = path.join(
45+
websiteDir,
46+
"src",
47+
"components",
48+
"GitHubSocialProof",
49+
"formatStars.ts",
50+
);
4051
// CI builds once and points us at the artifact; local runs self-build.
4152
const providedBuildDir = process.env.BROWSER_TEST_BUILD_DIR
4253
? path.resolve(process.env.BROWSER_TEST_BUILD_DIR)
@@ -361,17 +372,37 @@ function linksInHtml(html) {
361372
}));
362373
}
363374

364-
async function inspectNoJavaScriptSidebar() {
375+
// The homepage's no-JavaScript contract: navigation works and the trust band's
376+
// build-time star counts are baked into the HTML. One fetch feeds both checks.
377+
async function inspectNoJavaScriptHomepage() {
365378
const response = await fetch(siteUrl());
366379
if (!response.ok) fail(`no-JavaScript homepage returned ${response.status}`);
367-
const foundation = linksInHtml(await response.text()).find(
380+
const html = await response.text();
381+
inspectNoJavaScriptSidebar(html);
382+
await inspectNoJavaScriptStars(html);
383+
}
384+
385+
function inspectNoJavaScriptSidebar(html) {
386+
const foundation = linksInHtml(html).find(
368387
(link) => link.label === LABEL_FOUNDATIONS,
369388
);
370389
if (!foundation) fail("collapsed Foundations group has no SSR fallback link");
371390
if (new URL(foundation.href, siteUrl()).pathname !== FOUNDATIONS_FIRST_DOC)
372391
fail(`${LABEL_FOUNDATIONS} SSR fallback points to ${foundation.href}`);
373392
}
374393

394+
// Asserts each count is rendered server-side from the committed snapshot, tied
395+
// to its "stars" label so a stray number elsewhere cannot satisfy the check.
396+
async function inspectNoJavaScriptStars(html) {
397+
const { formatStars } = await import(pathToFileURL(FORMAT_STARS).href);
398+
const { projects } = JSON.parse(fs.readFileSync(STARS_SNAPSHOT, "utf8"));
399+
for (const { repo, stars } of projects) {
400+
const count = formatStars(stars).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
401+
if (!new RegExp(`>${count}</span>[^<]*<span[^>]*>stars</span>`).test(html))
402+
fail(`SSR homepage is missing the baked-in star count for ${repo}`);
403+
}
404+
}
405+
375406
// Scoped to the desktop docs sidebar. Keep in sync with the theme's canonical
376407
// scroller selector (DocSidebar/Desktop/index.tsx SIDEBAR_NAV_SELECTOR): the
377408
// aria-label is a translated theme string, the class is stable markup.
@@ -506,7 +537,6 @@ async function inspectMobileDrawerSidebar() {
506537
}
507538

508539
async function inspectSidebarNavigation() {
509-
await inspectNoJavaScriptSidebar();
510540
const page = await browser.newPage();
511541
await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 });
512542
await page.emulateMediaFeatures([
@@ -668,6 +698,7 @@ async function main() {
668698

669699
fs.mkdirSync(TRACE_DIR, { recursive: true });
670700
browser = await puppeteer.launch({ headless: true });
701+
await withRetry(() => inspectNoJavaScriptHomepage(), "inspectNoJavaScriptHomepage");
671702
await withRetry(() => inspectSidebarNavigation(), "inspectSidebarNavigation");
672703
await withRetry(() => inspectMobileDrawerSidebar(), "inspectMobileDrawerSidebar");
673704
await withRetry(() => inspectActiveChapterScroll(), "inspectActiveChapterScroll");

‎website/docs/intro.mdx‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,9 @@ That skill is the whole prize. Operate the machine well and the labor moves to i
2525

2626
**Free & open source — nothing to sell.** This book, the tools it references, and everything built around them are 100% open source, community-backed projects with no commercial agenda. No paid tiers. No enterprise licenses. Nothing to buy.
2727

28-
- <GitHubProjectSource
29-
name="Book source"
30-
repo="agenticoding/agenticoding.github.io"
31-
fallbackStars={102}
32-
/>
33-
- <GitHubProjectSource
34-
name="ChunkHound"
35-
repo="chunkhound/chunkhound"
36-
fallbackStars={1400}
37-
/>
38-
- <GitHubProjectSource
39-
name="Pi agenticoding extension"
40-
repo="agenticoding/pi-agenticoding"
41-
fallbackStars={18}
42-
/>
28+
- <GitHubProjectSource repo="agenticoding/agenticoding.github.io" />
29+
- <GitHubProjectSource repo="chunkhound/chunkhound" />
30+
- <GitHubProjectSource repo="agenticoding/pi-agenticoding" />
4331

4432
---
4533

‎website/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"docusaurus": "docusaurus",
77
"start": "docusaurus start",
88
"build": "docusaurus build",
9+
"stars:refresh": "node ../scripts/fetch-github-stars.js",
910
"test": "find src -name '*.test.ts' -print0 | xargs -0 node --test",
1011
"audit:breakpoints": "node ../scripts/audit-breakpoints.cjs",
1112
"audit:responsive-diagrams": "node ../scripts/audit-responsive-diagrams.cjs",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Compact star counts: exact under 1k, one decimal for thousands (dropped at
2+
// 10k+), so a header-scale number stays scannable. Shared with the browser
3+
// contract test, which asserts the value is baked into the no-JavaScript HTML.
4+
export function formatStars(stars: number): string {
5+
if (stars < 1_000) return String(stars);
6+
return `${(stars / 1_000).toFixed(stars < 10_000 ? 1 : 0).replace('.0', '')}k`;
7+
}

‎website/src/components/GitHubSocialProof/index.module.css‎

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
.hero,
21
.project {
32
display: inline-flex;
43
gap: var(--space-0h);
@@ -9,23 +8,6 @@
98
line-height: var(--lh-sm);
109
}
1110

12-
.hero {
13-
color: var(--text-body);
14-
font-family: var(--font-body);
15-
font-size: var(--text-lg);
16-
font-weight: 400;
17-
line-height: var(--lh-lg);
18-
vertical-align: baseline;
19-
/* Overrides Docusaurus's generic content-link underline. */
20-
text-decoration: none !important;
21-
}
22-
23-
.hero:focus-visible {
24-
outline: 2px solid var(--text-heading);
25-
outline-offset: var(--space-0h);
26-
}
27-
28-
.hero svg,
2911
.project svg {
3012
flex: 0 0 auto;
3113
width: var(--icon-sm);
@@ -42,15 +24,6 @@
4224
align-items: baseline;
4325
}
4426

45-
.hero svg {
46-
width: var(--icon-md);
47-
height: var(--icon-md);
48-
}
49-
50-
.hero .label {
51-
font-size: var(--text-sm);
52-
}
53-
5427
.count {
5528
color: var(--text-heading);
5629
font-weight: 700;
@@ -87,12 +60,6 @@
8760
}
8861

8962
@media (max-width: 576px) {
90-
.hero {
91-
display: flex;
92-
width: fit-content;
93-
margin-top: var(--space-2);
94-
}
95-
9663
.projectSource {
9764
display: flex;
9865
flex-wrap: wrap;

0 commit comments

Comments
 (0)