From 2f1ad03640d23006d7ba8f4b6e60da936b800ae2 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Tue, 22 Sep 2026 18:04:14 +1000 Subject: [PATCH] fix(www): prevent GitHub stars from falling back to zero (#50704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The www build saves failed GitHub star requests as zero, which the navigation displays as `0K`. This passes `GITHUB_TOKEN` through Turbo's build environment and preserves a valid previously generated count when a request fails. If there is no valid previous count, the navigation displays “GitHub” and the homepage contribution graphic omits the number. Cached fallback is available only when the previous generated content exists; rate limiting is a possible cause of the original failure, but has not been confirmed from deployment logs. ## To test - Run `pnpm --filter www exec vitest run scripts/lib/githubStars.test.ts` — all 16 tests pass, covering authenticated and anonymous requests, rate limiting, malformed responses, and missing or invalid cached content. - Build with a valid `GITHUB_TOKEN` and check that the navigation and homepage graphic show the star count. - Simulate a failed GitHub request with and without existing generated content. Confirm that a valid previous count is retained, or that no zero count appears when none is available. Verified locally: live GitHub fetch through the new loader, Turbo dry run with strict environment filtering and `GITHUB_TOKEN` allowed, Prettier, and ESLint (one existing default-export warning). Full build and browser checks have not been run. ## Summary by CodeRabbit - **Bug Fixes** - GitHub star counts are now displayed only when valid; otherwise, the interface shows a clear fallback label. - Star counts can fall back to cached data when GitHub is unavailable or returns invalid results. - Builds without GitHub data now complete gracefully instead of failing. - **Tests** - Added coverage for authenticated and unauthenticated requests, cached fallbacks, invalid responses, and clean builds. Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../(home)/_components/OpenSourceSection.tsx | 2 +- apps/www/components/Nav/GitHubButton.tsx | 2 +- apps/www/scripts/generateStaticContent.mjs | 30 ++----- apps/www/scripts/lib/githubStars.mjs | 31 +++++++ apps/www/scripts/lib/githubStars.test.ts | 81 +++++++++++++++++++ apps/www/turbo.jsonc | 1 + 6 files changed, 122 insertions(+), 25 deletions(-) create mode 100644 apps/www/scripts/lib/githubStars.mjs create mode 100644 apps/www/scripts/lib/githubStars.test.ts diff --git a/apps/www/app/(home)/_components/OpenSourceSection.tsx b/apps/www/app/(home)/_components/OpenSourceSection.tsx index e5047b2a673..4271c8a3276 100644 --- a/apps/www/app/(home)/_components/OpenSourceSection.tsx +++ b/apps/www/app/(home)/_components/OpenSourceSection.tsx @@ -55,7 +55,7 @@ const COLS = 72 const ROWS = 40 const githubStars = staticContent.githubStars -const TEXT = kFormatter(githubStars) +const TEXT = typeof githubStars === 'number' && githubStars > 0 ? kFormatter(githubStars) : '' const TEXT_MASK = textToPixelMask(TEXT) const TEXT_W = TEXT.length * 6 - 1 const TEXT_H = 7 diff --git a/apps/www/components/Nav/GitHubButton.tsx b/apps/www/components/Nav/GitHubButton.tsx index 46c7f728a4e..909187e5566 100644 --- a/apps/www/components/Nav/GitHubButton.tsx +++ b/apps/www/components/Nav/GitHubButton.tsx @@ -45,7 +45,7 @@ const GitHubButton = () => { fill="currentColor" /> - {kFormatter(githubStars)} + {typeof githubStars === 'number' && githubStars > 0 ? kFormatter(githubStars) : 'GitHub'} diff --git a/apps/www/scripts/generateStaticContent.mjs b/apps/www/scripts/generateStaticContent.mjs index 04f694b38c0..8c58ef78149 100644 --- a/apps/www/scripts/generateStaticContent.mjs +++ b/apps/www/scripts/generateStaticContent.mjs @@ -8,6 +8,8 @@ import advancedFormat from 'dayjs/plugin/advancedFormat.js' import utc from 'dayjs/plugin/utc.js' import matter from 'gray-matter' +import { getGitHubStars } from './lib/githubStars.mjs' + /** * Plain `node` does not read `.env` / `.env.local` (Next.js loads those when you run `next`). * Minimal parser: no extra dependency; `.env` first, then `.env.local` overrides. @@ -232,26 +234,8 @@ const getLatestBlogPosts = async () => { return latestPosts } -let stars = 0 - // GitHub Stars -const fetchOctoData = async () => { - const { Octokit } = await import('@octokit/core') - const octokit = new Octokit(process.env.GITHUB_TOKEN ? { auth: process.env.GITHUB_TOKEN } : {}) - const res = await octokit.request('GET /repos/{org}/{repo}', { - org: 'supabase', - repo: 'supabase', - type: 'public', - }) - - return res.data?.stargazers_count -} - -try { - stars = await fetchOctoData() -} catch (error) { - console.warn('Error fetching GitHub stars:', error) -} +const stars = await getGitHubStars(path.join(wwwRoot, '.generated/staticContent/_index.json')) // Careers Jobs count const getCareerCount = async () => { @@ -397,9 +381,8 @@ async function generateChangelogContent() { const { getPublishedChangelogEntries, fetchChangelogEntryFilesFromTarball, CHANGE_TYPE_LABELS } = await import('../lib/changelog-entries-core.mjs') - const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = await import( - '../lib/changelog-rss.mjs' - ) + const { generateChangelogRssXml, generateChangelogTagRssXml, labelToFileSlug } = + await import('../lib/changelog-rss.mjs') const { createAppAuth } = await import('@octokit/auth-app') const { Octokit } = await import('@octokit/core') const octokit = new Octokit({ @@ -460,7 +443,8 @@ async function generateChangelogContent() { // LLM-friendly changelog markdown index (RSS remains canonical syndication format). const mdSections = entries.map((entry) => { const date = dayjs(entry.sortDate).isValid() ? dayjs(entry.sortDate).format('YYYY-MM-DD') : '' - const changeType = CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type + const changeType = + CHANGE_TYPE_LABELS[entry.frontmatter.change_type] ?? entry.frontmatter.change_type const products = (entry.frontmatter.affected_products ?? []).join(', ') const meta = [ date, diff --git a/apps/www/scripts/lib/githubStars.mjs b/apps/www/scripts/lib/githubStars.mjs new file mode 100644 index 00000000000..74464a50acc --- /dev/null +++ b/apps/www/scripts/lib/githubStars.mjs @@ -0,0 +1,31 @@ +import { readFile } from 'node:fs/promises' +import { Octokit } from '@octokit/core' + +const isValidCount = (count) => Number.isSafeInteger(count) && count > 0 + +/** Preserve a successful count when GitHub is unavailable; null means unknown. */ +export async function getGitHubStars(cachePath) { + try { + const octokit = new Octokit(process.env.GITHUB_TOKEN ? { auth: process.env.GITHUB_TOKEN } : {}) + const { data } = await octokit.request('GET /repos/{org}/{repo}', { + org: 'supabase', + repo: 'supabase', + type: 'public', + }) + if (!isValidCount(data?.stargazers_count)) { + throw new Error('GitHub returned an invalid star count') + } + return data.stargazers_count + } catch (error) { + console.warn('Error fetching GitHub stars:', error.message) + } + + try { + const { githubStars } = JSON.parse(await readFile(cachePath, 'utf8')) + if (isValidCount(githubStars)) return githubStars + } catch { + // A clean build may have no previously generated content. + } + + return null +} diff --git a/apps/www/scripts/lib/githubStars.test.ts b/apps/www/scripts/lib/githubStars.test.ts new file mode 100644 index 00000000000..ea795ca1d5a --- /dev/null +++ b/apps/www/scripts/lib/githubStars.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { getGitHubStars } from './githubStars.mjs' + +const { request, readFile, Octokit } = vi.hoisted(() => ({ + request: vi.fn(), + readFile: vi.fn(), + Octokit: vi.fn(), +})) + +vi.mock('@octokit/core', () => ({ Octokit })) +vi.mock('node:fs/promises', () => ({ readFile })) + +const cachePath = '/staticContent/_index.json' + +describe('getGitHubStars', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.stubEnv('GITHUB_TOKEN', '') + Octokit.mockImplementation(function () { + return { request } + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + readFile.mockRejectedValue(new Error('ENOENT')) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('uses the live count and authenticates when a token is available', async () => { + vi.stubEnv('GITHUB_TOKEN', 'test-token') + request.mockResolvedValue({ data: { stargazers_count: 110565 } }) + + expect(await getGitHubStars(cachePath)).toBe(110565) + expect(Octokit).toHaveBeenCalledWith({ auth: 'test-token' }) + expect(readFile).not.toHaveBeenCalled() + }) + + it('can fetch public stars without a token', async () => { + request.mockResolvedValue({ data: { stargazers_count: 110565 } }) + + expect(await getGitHubStars(cachePath)).toBe(110565) + expect(Octokit).toHaveBeenCalledWith({}) + }) + + it('keeps the previous count when GitHub rate limits the build', async () => { + request.mockRejectedValue(Object.assign(new Error('API rate limit exceeded'), { status: 403 })) + readFile.mockResolvedValue(JSON.stringify({ githubStars: 109037 })) + + expect(await getGitHubStars(cachePath)).toBe(109037) + expect(readFile).toHaveBeenCalledWith(cachePath, 'utf8') + }) + + it.each([undefined, null, 0, -1, '110565', NaN, Infinity, 1.5])( + 'falls back to the previous count for invalid API data: %s', + async (stargazers_count) => { + request.mockResolvedValue({ data: { stargazers_count } }) + readFile.mockResolvedValue(JSON.stringify({ githubStars: 109037 })) + + expect(await getGitHubStars(cachePath)).toBe(109037) + } + ) + + it('returns unknown when the request fails on a clean build', async () => { + request.mockRejectedValue(new Error('Network error')) + + expect(await getGitHubStars(cachePath)).toBeNull() + }) + + it.each(['invalid json', '{}', '{"githubStars":0}', '{"githubStars":null}'])( + 'does not reuse invalid cached content: %s', + async (content) => { + request.mockRejectedValue(new Error('Network error')) + readFile.mockResolvedValue(content) + + expect(await getGitHubStars(cachePath)).toBeNull() + } + ) +}) diff --git a/apps/www/turbo.jsonc b/apps/www/turbo.jsonc index 61fef916f7d..acbc04712ad 100644 --- a/apps/www/turbo.jsonc +++ b/apps/www/turbo.jsonc @@ -15,6 +15,7 @@ "CI", "CF_ACCESS_CLIENT_ID", "CF_ACCESS_CLIENT_SECRET", + "GITHUB_TOKEN", "NEXT_PUBLIC_MISC_USE_URL", "NEXT_PUBLIC_MISC_USE_ANON_KEY", "NEXT_PUBLIC_STUDIO_URL",