fix(www): prevent GitHub stars from falling back to zero (#50704)

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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-09-22 18:04:14 +10:00
1 parent 845e1fb5df
commit 2f1ad03640
6 files changed
+122 -25

No files matched your search

@@ -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
+1 -1
View File
@@ -45,7 +45,7 @@ const GitHubButton = () => {
fill="currentColor"
/>
</svg>
{kFormatter(githubStars)}
{typeof githubStars === 'number' && githubStars > 0 ? kFormatter(githubStars) : 'GitHub'}
</span>
</a>
</Button>
+7 -23
View File
@@ -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,
+31
View File
@@ -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
}
+81
View File
@@ -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()
}
)
})
+1
View File
@@ -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",