Compare commits

...

1 Commits

Author SHA1 Message Date
Benjamin Lu
9f6e0f8b74 fix: standardize website build data sources 2026-05-11 14:42:18 -07:00
16 changed files with 802 additions and 117 deletions

View File

@@ -1,6 +1,6 @@
# Description: Manual workflow to refresh the apps/website Ashby roles snapshot
# and open a PR. Merging the PR triggers the existing Vercel website production
# deploy via ci-vercel-website-preview.yaml.
# Description: Manual workflow to refresh apps/website data snapshots and open
# a PR. Merging the PR triggers the existing Vercel website production deploy
# via ci-vercel-website-preview.yaml.
name: 'Release: Website'
on:
@@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
refresh-snapshot:
refresh-snapshots:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
permissions:
@@ -31,15 +31,20 @@ jobs:
api_key: ${{ secrets.WEBSITE_ASHBY_API_KEY }}
job_board_name: ${{ secrets.WEBSITE_ASHBY_JOB_BOARD_NAME }}
- name: Refresh GitHub stars snapshot
run: pnpm --filter @comfyorg/website github-stars:refresh-snapshot
- name: Create Pull Request
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: 'chore(website): refresh Ashby roles snapshot'
title: 'chore(website): refresh Ashby roles snapshot'
commit-message: 'chore(website): refresh data snapshots'
title: 'chore(website): refresh data snapshots'
body: |
Automated refresh of `apps/website/src/data/ashby-roles.snapshot.json`
from the Ashby job board API.
Automated refresh of website build-data snapshots:
- `apps/website/src/data/ashby-roles.snapshot.json`
- `apps/website/src/data/github-stars.snapshot.json`
**Flow:**
1. `Release: Website` workflow ran (manual trigger).
@@ -47,12 +52,11 @@ jobs:
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.
The snapshot fallback in `apps/website/src/utils/ashby.ts` remains
intact: builds without `WEBSITE_ASHBY_API_KEY` continue to use the
committed snapshot.
The snapshot fallback paths remain intact: builds use committed
snapshots whenever a live build-data source is unavailable.
Triggered by workflow run `${{ github.run_id }}`.
branch: chore/refresh-ashby-snapshot-${{ github.run_id }}
branch: chore/refresh-website-data-snapshots-${{ github.run_id }}
base: main
labels: |
Release:Website

View File

@@ -2,6 +2,20 @@
Marketing/brand website built with Astro + Vue.
## Build-time data sources
External data used during static generation should use the shared build-data
source helpers in `src/utils/buildDataSource.ts` and
`src/utils/buildDataReporter.ts`. Each source owns its domain parsing, but the
shared machinery owns the repeated lifecycle:
1. Fetch fresh data once per build process.
2. Validate and map to a committed snapshot shape.
3. Fall back to the committed snapshot on fetch/schema/network failure.
4. Emit GitHub Actions annotations and `$GITHUB_STEP_SUMMARY` rows once per
build process.
5. Refresh snapshots through package scripts that write atomically.
## Ashby careers integration
`/careers` and `/zh-CN/careers` are rendered from Ashby's public job board
@@ -113,6 +127,19 @@ git commit apps/website/src/data/ashby-roles.snapshot.json
The script exits non-zero on any non-fresh outcome so stale/empty
snapshots can't be accidentally committed.
## GitHub stars integration
The navigation star badge is rendered from the GitHub repository API at build
time through the same snapshot-fallback lifecycle.
- Runtime source: `src/utils/github.ts`
- Snapshot: `src/data/github-stars.snapshot.json`
- CI reporter: `src/utils/github.ci.ts`
- Refresh script: `pnpm --filter @comfyorg/website github-stars:refresh-snapshot`
`WEBSITE_GITHUB_STARS_OVERRIDE` remains available for visual snapshots and local
determinism. It must be a non-negative integer and is build-time only.
## HubSpot contact form
The contact page uses HubSpot's hosted form embed for the interest form:
@@ -146,3 +173,5 @@ renders the documented embed container.
- `pnpm test:unit` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E tests (requires `pnpm build` first)
- `pnpm ashby:refresh-snapshot` — refresh the committed careers snapshot
- `pnpm github-stars:refresh-snapshot` — refresh the committed GitHub stars
snapshot

View File

@@ -15,7 +15,8 @@
"test:e2e:local": "cross-env PLAYWRIGHT_LOCAL=1 playwright test",
"test:visual": "playwright test --project visual",
"test:visual:update": "playwright test --project visual --update-snapshots",
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts"
"ashby:refresh-snapshot": "tsx ./scripts/refresh-ashby-snapshot.ts",
"github-stars:refresh-snapshot": "tsx ./scripts/refresh-github-stars-snapshot.ts"
},
"dependencies": {
"@astrojs/sitemap": "catalog:",

View File

@@ -0,0 +1,29 @@
import { renameSync, writeFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { fetchGitHubStarsForBuild } from '../src/utils/github'
const snapshotPath = fileURLToPath(
new URL('../src/data/github-stars.snapshot.json', import.meta.url)
)
const tempPath = `${snapshotPath}.tmp`
const outcome = await fetchGitHubStarsForBuild()
if (outcome.status !== 'fresh') {
const reason = 'reason' in outcome ? outcome.reason : '(none)'
console.error(
`Snapshot refresh aborted. Outcome: ${outcome.status}; reason: ${reason}`
)
process.exit(1)
}
writeFileSync(
tempPath,
JSON.stringify(outcome.snapshot, null, 2) + '\n',
'utf8'
)
renameSync(tempPath, snapshotPath)
process.stdout.write(
`Wrote snapshot with ${outcome.snapshot.stargazersCount} star(s) to ${snapshotPath}\n`
)

View File

@@ -0,0 +1,5 @@
{
"fetchedAt": "2026-05-11T21:41:14.168Z",
"repository": "Comfy-Org/ComfyUI",
"stargazersCount": 112467
}

View File

@@ -0,0 +1,23 @@
export interface GitHubStarsSnapshot {
fetchedAt: string
repository: 'Comfy-Org/ComfyUI'
stargazersCount: number
}
export function isGitHubStarsSnapshot(
value: unknown
): value is GitHubStarsSnapshot {
if (value === null || typeof value !== 'object') return false
const candidate = value as {
fetchedAt?: unknown
repository?: unknown
stargazersCount?: unknown
}
return (
typeof candidate.fetchedAt === 'string' &&
candidate.repository === 'Comfy-Org/ComfyUI' &&
typeof candidate.stargazersCount === 'number' &&
Number.isSafeInteger(candidate.stargazersCount) &&
candidate.stargazersCount >= 0
)
}

View File

@@ -5,7 +5,8 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
import { fetchGitHubStarsForBuild, formatStarCount } from '../utils/github'
import { reportGitHubStarsOutcome } from '../utils/github.ci'
interface Props {
title: string
@@ -30,8 +31,17 @@ const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
const rawLocale = Astro.currentLocale ?? 'en'
const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
const githubStarsOutcome = await fetchGitHubStarsForBuild()
reportGitHubStarsOutcome(githubStarsOutcome)
if (githubStarsOutcome.status === 'failed') {
throw new Error(
`GitHub stars fetch failed and no snapshot is available. Reason: ${githubStarsOutcome.reason}. ` +
'Run `pnpm --filter @comfyorg/website github-stars:refresh-snapshot` locally and commit the snapshot.'
)
}
const githubStars = formatStarCount(githubStarsOutcome.snapshot.stargazersCount)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD

View File

@@ -1,39 +1,31 @@
import { appendFileSync } from 'node:fs'
import type { FetchOutcome } from './ashby'
let hasReported = false
import {
createBuildDataReporter,
describeSnapshotAge,
escapeAnnotation
} from './buildDataReporter'
export function resetAshbyReporterForTests(): void {
hasReported = false
}
const ashbyReporter = createBuildDataReporter({
summaryHeading: '## 💼 Careers (Ashby)\n',
buildAnnotations,
buildSummaryRows
})
export function reportAshbyOutcome(outcome: FetchOutcome): void {
if (hasReported) return
hasReported = true
const lines = buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(summaryPath, buildStepSummary(outcome))
} catch {
// Writing the summary is best-effort; do not fail the build if the
// runner's summary file is unavailable (e.g. local dev).
}
}
}
export const resetAshbyReporterForTests = ashbyReporter.resetForTests
export const reportAshbyOutcome = ashbyReporter.report
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') {
if (outcome.droppedCount === 0) return []
const roleCount = outcome.droppedCount === 1 ? 'role' : 'roles'
const drops = outcome.droppedRoles
.map((d) => ` - ${d.title ? `"${d.title}"` : '(untitled)'}: ${d.reason}`)
.map(
(d) =>
` - ${escapeAnnotation(
d.title ? `"${d.title}"` : '(untitled)'
)}: ${escapeAnnotation(d.reason)}`
)
.join('%0A')
return [
`::warning title=Ashby: dropped ${outcome.droppedCount} invalid ${roleCount}::Dropped roles:%0A${drops}%0A%0AAction items:%0A 1. Fix the posting in Ashby admin (e.g. assign a department, fix the URL).%0A 2. If the v1 schema is too strict for a legitimate case, relax the field in apps/website/src/utils/ashby.schema.ts and add a test.%0A 3. These roles will not appear on the careers page until fixed.`
@@ -63,12 +55,7 @@ function staleAnnotation(reason: string): string {
return `::warning title=Ashby API unavailable::${escaped}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check https://status.ashbyhq.com%0A 2. Re-run this workflow once Ashby is healthy.`
}
function escapeAnnotation(value: string): string {
return value.replace(/\r?\n/g, '%0A').replace(/\r/g, '%0D')
}
function buildStepSummary(outcome: FetchOutcome): string {
const header = '## 💼 Careers (Ashby)\n'
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
@@ -95,19 +82,5 @@ function buildStepSummary(outcome: FetchOutcome): string {
rows.push(['Reason', outcome.reason])
}
const table =
'| | |\n|---|---|\n' +
rows.map(([k, v]) => `| **${k}** | ${v} |`).join('\n') +
'\n'
return `${header}${table}\n`
}
function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
return rows
}

View File

@@ -9,25 +9,24 @@ import {
AshbyJobBoardResponseSchema,
AshbyJobPostingSchema
} from './ashby.schema'
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
import { createBuildDataSource } from './buildDataSource'
const DEFAULT_BASE_URL = 'https://api.ashbyhq.com'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedRole {
interface DroppedRole {
title: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: RolesSnapshot
droppedCount: number
droppedRoles: DroppedRole[]
}
| { status: 'stale'; snapshot: RolesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FreshRolesData {
droppedCount: number
droppedRoles: DroppedRole[]
}
export type FetchOutcome = BuildDataOutcome<RolesSnapshot, FreshRolesData>
interface FetchRolesOptions {
apiKey?: string
@@ -40,56 +39,50 @@ interface FetchRolesOptions {
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
const ashbySource = createBuildDataSource<
FetchRolesOptions,
RolesSnapshot,
FreshRolesData
>({
name: 'Ashby roles',
fetchFresh: fetchFreshRoles,
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
getCacheKey: getAshbyCacheKey
})
export function resetAshbyFetcherForTests(): void {
inflight = undefined
}
export const resetAshbyFetcherForTests = ashbySource.resetForTests
export const fetchRolesForBuild = ashbySource.fetchForBuild
export function fetchRolesForBuild(
options: FetchRolesOptions = {}
): Promise<FetchOutcome> {
inflight ??= doFetchRolesForBuild(options)
return inflight
}
async function doFetchRolesForBuild(
async function fetchFreshRoles(
options: FetchRolesOptions
): Promise<FetchOutcome> {
): Promise<BuildDataFetchResult<RolesSnapshot, FreshRolesData>> {
const apiKey = options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY
const boardName =
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME
if (!apiKey || !boardName) {
return fallback(
'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME',
options.snapshotUrl
)
return {
kind: 'err',
reason: 'missing WEBSITE_ASHBY_API_KEY or WEBSITE_ASHBY_JOB_BOARD_NAME'
}
}
const result = await tryFetchAndParse(apiKey, boardName, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
kind: 'ok',
snapshot: {
fetchedAt: new Date().toISOString(),
departments: result.departments
},
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
data: {
droppedCount: result.droppedRoles.length,
droppedRoles: result.droppedRoles
}
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
return result
}
interface FetchOk {
@@ -297,3 +290,15 @@ function isRolesSnapshot(value: unknown): value is RolesSnapshot {
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getAshbyCacheKey(options: FetchRolesOptions): string {
return JSON.stringify({
apiKey: options.apiKey ?? process.env.WEBSITE_ASHBY_API_KEY ?? '',
boardName:
options.boardName ?? process.env.WEBSITE_ASHBY_JOB_BOARD_NAME ?? '',
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
retryDelaysMs: options.retryDelaysMs ?? RETRY_DELAYS_MS,
snapshotUrl: options.snapshotUrl?.href ?? ''
})
}

View File

@@ -0,0 +1,78 @@
import { appendFileSync } from 'node:fs'
import type { BuildDataOutcome } from './buildDataSource'
type SummaryRows = Array<[string, string]>
interface BuildDataReporterConfig<
TSnapshot,
TFreshData extends object = object
> {
summaryHeading: string
buildAnnotations: (
outcome: BuildDataOutcome<TSnapshot, TFreshData>
) => string[]
buildSummaryRows: (
outcome: BuildDataOutcome<TSnapshot, TFreshData>
) => SummaryRows
}
export function createBuildDataReporter<
TSnapshot,
TFreshData extends object = object
>(config: BuildDataReporterConfig<TSnapshot, TFreshData>) {
let hasReported = false
function resetForTests(): void {
hasReported = false
}
function report(outcome: BuildDataOutcome<TSnapshot, TFreshData>): void {
if (hasReported) return
hasReported = true
const lines = config.buildAnnotations(outcome)
for (const line of lines) {
process.stdout.write(`${line}\n`)
}
const summaryPath = process.env.GITHUB_STEP_SUMMARY
if (summaryPath) {
try {
appendFileSync(
summaryPath,
buildStepSummary(
config.summaryHeading,
config.buildSummaryRows(outcome)
)
)
} catch {
// Best-effort only; a missing local summary file must not fail a build.
}
}
}
return { report, resetForTests }
}
export function escapeAnnotation(value: string): string {
return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
}
export function describeSnapshotAge(fetchedAt: string): string {
const fetched = new Date(fetchedAt).getTime()
if (Number.isNaN(fetched)) return 'unknown'
const days = Math.floor((Date.now() - fetched) / 86_400_000)
if (days <= 0) return 'today'
if (days === 1) return '1 day'
return `${days} days`
}
function buildStepSummary(heading: string, rows: SummaryRows): string {
const table =
'| | |\n|---|---|\n' +
rows.map(([key, value]) => `| **${key}** | ${value} |`).join('\n') +
'\n'
return `${heading}${table}\n`
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest'
import { createBuildDataSource } from './buildDataSource'
interface TestOptions {
cacheKey?: string
snapshot?: string
result?: 'fresh' | 'stale' | 'failed'
}
describe('createBuildDataSource', () => {
it('returns fresh data without reading the snapshot', async () => {
const readSnapshot = vi.fn(async () => 'snapshot')
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({
kind: 'ok',
snapshot: 'fresh',
data: {}
}),
readSnapshot
})
await expect(source.fetchForBuild()).resolves.toEqual({
status: 'fresh',
snapshot: 'fresh'
})
expect(readSnapshot).not.toHaveBeenCalled()
})
it('falls back to a snapshot when fresh fetch fails', async () => {
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
readSnapshot: async (options) => options.snapshot ?? null
})
await expect(
source.fetchForBuild({ snapshot: 'last-known-good' })
).resolves.toEqual({
status: 'stale',
snapshot: 'last-known-good',
reason: 'HTTP 500'
})
})
it('returns failed when fresh fetch and snapshot are unavailable', async () => {
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh: async () => ({ kind: 'err', reason: 'HTTP 500' }),
readSnapshot: async () => null
})
await expect(source.fetchForBuild()).resolves.toEqual({
status: 'failed',
reason: 'HTTP 500'
})
})
it('memoizes matching cache keys and rejects mismatched cache keys', async () => {
const fetchFresh = vi.fn(async () => ({
kind: 'ok' as const,
snapshot: 'fresh',
data: {}
}))
const source = createBuildDataSource<TestOptions, string>({
name: 'test',
fetchFresh,
readSnapshot: async () => null,
getCacheKey: (options) => options.cacheKey ?? 'default'
})
const first = await source.fetchForBuild({ cacheKey: 'a' })
const second = await source.fetchForBuild({ cacheKey: 'a' })
expect(first).toBe(second)
expect(fetchFresh).toHaveBeenCalledTimes(1)
expect(() => source.fetchForBuild({ cacheKey: 'b' })).toThrow(
/called twice with different options/
)
})
})

View File

@@ -0,0 +1,83 @@
export type BuildDataOutcome<TSnapshot, TFreshData extends object = object> =
| ({ status: 'fresh'; snapshot: TSnapshot } & TFreshData)
| { status: 'stale'; snapshot: TSnapshot; reason: string }
| { status: 'failed'; reason: string }
export type BuildDataFetchResult<
TSnapshot,
TFreshData extends object = object
> =
| { kind: 'ok'; snapshot: TSnapshot; data: TFreshData }
| { kind: 'err'; reason: string }
interface BuildDataSourceConfig<
TOptions extends object,
TSnapshot,
TFreshData extends object
> {
name: string
fetchFresh: (
options: TOptions
) => Promise<BuildDataFetchResult<TSnapshot, TFreshData>>
readSnapshot: (options: TOptions) => Promise<TSnapshot | null>
getCacheKey?: (options: TOptions) => string
}
export function createBuildDataSource<
TOptions extends object,
TSnapshot,
TFreshData extends object = object
>(config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>) {
let inflight: Promise<BuildDataOutcome<TSnapshot, TFreshData>> | undefined
let inflightCacheKey: string | undefined
function resetForTests(): void {
inflight = undefined
inflightCacheKey = undefined
}
function fetchForBuild(
options = {} as TOptions
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
const cacheKey = config.getCacheKey?.(options) ?? 'default'
if (inflight) {
if (inflightCacheKey !== cacheKey) {
throw new Error(
`${config.name} fetcher called twice with different options; reset between distinct configurations`
)
}
return inflight
}
inflightCacheKey = cacheKey
inflight = doFetchForBuild(config, options)
return inflight
}
return { fetchForBuild, resetForTests }
}
async function doFetchForBuild<
TOptions extends object,
TSnapshot,
TFreshData extends object
>(
config: BuildDataSourceConfig<TOptions, TSnapshot, TFreshData>,
options: TOptions
): Promise<BuildDataOutcome<TSnapshot, TFreshData>> {
const result = await config.fetchFresh(options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: result.snapshot,
...result.data
}
}
const snapshot = await config.readSnapshot(options)
if (snapshot) {
return { status: 'stale', snapshot, reason: result.reason }
}
return { status: 'failed', reason: result.reason }
}

View File

@@ -0,0 +1,83 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FetchOutcome } from './github'
import {
reportGitHubStarsOutcome,
resetGitHubStarsReporterForTests
} from './github.ci'
function snapshot() {
return {
fetchedAt: new Date().toISOString(),
repository: 'Comfy-Org/ComfyUI' as const,
stargazersCount: 112464
}
}
function freshOutcome(): FetchOutcome {
return {
status: 'fresh',
snapshot: snapshot()
}
}
describe('reportGitHubStarsOutcome', () => {
let writeSpy: ReturnType<typeof vi.spyOn>
let summaryDir: string
let summaryPath: string
const originalSummary = process.env.GITHUB_STEP_SUMMARY
beforeEach(() => {
resetGitHubStarsReporterForTests()
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
summaryDir = mkdtempSync(join(tmpdir(), 'github-stars-summary-'))
summaryPath = join(summaryDir, 'summary.md')
writeFileSync(summaryPath, '')
process.env.GITHUB_STEP_SUMMARY = summaryPath
})
afterEach(() => {
writeSpy.mockRestore()
rmSync(summaryDir, { recursive: true, force: true })
if (originalSummary === undefined) delete process.env.GITHUB_STEP_SUMMARY
else process.env.GITHUB_STEP_SUMMARY = originalSummary
})
it('emits no annotation on a fresh outcome', () => {
reportGitHubStarsOutcome(freshOutcome())
expect(writeSpy).not.toHaveBeenCalled()
expect(readFileSync(summaryPath, 'utf8')).toContain('Fresh')
})
it('emits exactly one stale warning across repeated calls', () => {
const outcome: FetchOutcome = {
status: 'stale',
reason: 'HTTP 403 rate limited',
snapshot: snapshot()
}
reportGitHubStarsOutcome(outcome)
reportGitHubStarsOutcome(outcome)
expect(writeSpy).toHaveBeenCalledTimes(1)
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::warning title=GitHub stars unavailable')
expect(readFileSync(summaryPath, 'utf8')).toContain('Stale')
})
it('emits an error for failed outcomes', () => {
reportGitHubStarsOutcome({
status: 'failed',
reason: 'HTTP 500 Server Error'
})
const annotation = writeSpy.mock.calls[0]![0] as string
expect(annotation).toContain('::error title=GitHub stars fetch failed')
expect(readFileSync(summaryPath, 'utf8')).toContain('Failed')
})
})

View File

@@ -0,0 +1,56 @@
import type { FetchOutcome } from './github'
import {
createBuildDataReporter,
describeSnapshotAge,
escapeAnnotation
} from './buildDataReporter'
const githubStarsReporter = createBuildDataReporter({
summaryHeading: '## GitHub stars\n',
buildAnnotations,
buildSummaryRows
})
export const resetGitHubStarsReporterForTests =
githubStarsReporter.resetForTests
export const reportGitHubStarsOutcome = githubStarsReporter.report
function buildAnnotations(outcome: FetchOutcome): string[] {
if (outcome.status === 'fresh') return []
if (outcome.status === 'stale') {
return [
`::warning title=GitHub stars unavailable::${escapeAnnotation(
outcome.reason
)}. Using last-known-good snapshot.%0A%0AAction items:%0A 1. Check GitHub API availability/rate limits.%0A 2. Re-run the build once GitHub is healthy.%0A 3. To refresh the fallback value, run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\` and commit apps/website/src/data/github-stars.snapshot.json.`
]
}
return [
`::error title=GitHub stars fetch failed and no snapshot is available::Cannot render website navigation stars.%0A%0AReason: ${escapeAnnotation(
outcome.reason
)}%0A%0AAction items:%0A 1. Run \`pnpm --filter @comfyorg/website github-stars:refresh-snapshot\`.%0A 2. Commit apps/website/src/data/github-stars.snapshot.json.%0A 3. Push and re-run CI.`
]
}
function buildSummaryRows(outcome: FetchOutcome): Array<[string, string]> {
const rows: Array<[string, string]> = []
if (outcome.status === 'fresh') {
rows.push(['Status', 'Fresh (fetched from GitHub)'])
rows.push(['Repository', outcome.snapshot.repository])
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
} else if (outcome.status === 'stale') {
rows.push(['Status', 'Stale (using snapshot - GitHub fetch failed)'])
rows.push(['Repository', outcome.snapshot.repository])
rows.push(['Stars', String(outcome.snapshot.stargazersCount)])
rows.push(['Reason', outcome.reason])
rows.push(['Snapshot age', describeSnapshotAge(outcome.snapshot.fetchedAt)])
} else {
rows.push(['Status', 'Failed (no snapshot available)'])
rows.push(['Reason', outcome.reason])
}
return rows
}

View File

@@ -1,10 +1,50 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { fetchGitHubStars, formatStarCount } from './github'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitHubStarsSnapshot } from '../data/githubStars'
import {
fetchGitHubStars,
fetchGitHubStarsForBuild,
formatStarCount,
resetGitHubStarsFetcherForTests
} from './github'
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
const base: ResponseInit = {
status: 200,
headers: { 'content-type': 'application/json' }
}
return new Response(JSON.stringify(body), { ...base, ...init })
}
function makeSnapshot(stargazersCount = 111_605): GitHubStarsSnapshot {
return {
fetchedAt: '2026-05-06T00:00:00.000Z',
repository: 'Comfy-Org/ComfyUI',
stargazersCount
}
}
function withSnapshotDir(snapshot: GitHubStarsSnapshot | null): URL {
const dir = mkdtempSync(join(tmpdir(), 'github-stars-test-'))
const file = join(dir, 'github-stars.snapshot.json')
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
return pathToFileURL(file)
}
describe('fetchGitHubStars', () => {
const savedOverride = process.env.WEBSITE_GITHUB_STARS_OVERRIDE
beforeEach(() => {
resetGitHubStarsFetcherForTests()
delete process.env.WEBSITE_GITHUB_STARS_OVERRIDE
})
afterEach(() => {
vi.restoreAllMocks()
if (savedOverride === undefined)
@@ -16,16 +56,86 @@ describe('fetchGitHubStars', () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110000'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).resolves.toBe(110000)
await expect(fetchGitHubStars()).resolves.toBe(110000)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when the build-time override is malformed', async () => {
process.env.WEBSITE_GITHUB_STARS_OVERRIDE = '110K'
const fetchMock = vi.spyOn(globalThis, 'fetch')
await expect(fetchGitHubStars('Comfy-Org', 'ComfyUI')).rejects.toThrow(
await expect(fetchGitHubStars()).rejects.toThrow(
'WEBSITE_GITHUB_STARS_OVERRIDE must be a non-negative integer'
)
expect(fetchMock).not.toHaveBeenCalled()
})
it('returns fresh stars when the API succeeds', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: 112464 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl })
expect(outcome.status).toBe('fresh')
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.repository).toBe('Comfy-Org/ComfyUI')
expect(outcome.snapshot.stargazersCount).toBe(112464)
})
it('memoizes build-time star fetches within a single process', async () => {
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: 110000 })
)
const [a, b] = await Promise.all([
fetchGitHubStarsForBuild({ fetchImpl }),
fetchGitHubStarsForBuild({ fetchImpl })
])
expect(a).toBe(b)
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
it('falls back to the committed snapshot when GitHub fetch fails', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({}, { status: 403 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^HTTP 403/)
expect(outcome.snapshot.stargazersCount).toBe(111605)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('returns failed when both GitHub fetch and snapshot are unavailable', async () => {
const snapshotUrl = withSnapshotDir(null)
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({}, { status: 403 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('failed')
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
it('rejects invalid numeric star counts before caching them', async () => {
const snapshotUrl = withSnapshotDir(makeSnapshot())
const fetchImpl = vi.fn<typeof fetch>(async () =>
response({ stargazers_count: -1 })
)
const outcome = await fetchGitHubStarsForBuild({ fetchImpl, snapshotUrl })
expect(outcome.status).toBe('stale')
if (outcome.status !== 'stale') return
expect(outcome.reason).toMatch(/^response schema validation failed/)
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
})
})

View File

@@ -1,22 +1,129 @@
export async function fetchGitHubStars(
owner: string,
repo: string
): Promise<number | null> {
const override = readGitHubStarsOverride()
if (override !== undefined) return override
import { readFile } from 'node:fs/promises'
import type { GitHubStarsSnapshot } from '../data/githubStars'
import type { BuildDataFetchResult, BuildDataOutcome } from './buildDataSource'
import bundledSnapshot from '../data/github-stars.snapshot.json' with { type: 'json' }
import { isGitHubStarsSnapshot } from '../data/githubStars'
import { createBuildDataSource } from './buildDataSource'
const GITHUB_REPOSITORY = 'Comfy-Org/ComfyUI'
const GITHUB_REPO_API_URL = `https://api.github.com/repos/${GITHUB_REPOSITORY}`
export type FetchOutcome = BuildDataOutcome<GitHubStarsSnapshot>
interface FetchGitHubStarsOptions {
fetchImpl?: typeof fetch
snapshotUrl?: URL
}
const githubStarsSource = createBuildDataSource<
FetchGitHubStarsOptions,
GitHubStarsSnapshot
>({
name: 'GitHub stars',
fetchFresh: fetchFreshGitHubStars,
readSnapshot: (options) => readSnapshot(options.snapshotUrl),
getCacheKey: getGitHubStarsCacheKey
})
export const resetGitHubStarsFetcherForTests = githubStarsSource.resetForTests
export const fetchGitHubStarsForBuild = githubStarsSource.fetchForBuild
export async function fetchGitHubStars(
fetchImpl: typeof fetch = fetch
): Promise<number | null> {
const result = await fetchFreshGitHubStars({ fetchImpl })
if (result.kind === 'err') return null
return result.snapshot.stargazersCount
}
async function fetchFreshGitHubStars(
options: FetchGitHubStarsOptions
): Promise<BuildDataFetchResult<GitHubStarsSnapshot>> {
const override = readGitHubStarsOverride()
if (override !== undefined) {
return {
kind: 'ok',
snapshot: makeSnapshot(override),
data: {}
}
}
const response = await callOnce(options.fetchImpl ?? fetch)
if (response.kind === 'err') return response
const count = readStargazerCount(response.body)
if (count === null) {
return {
kind: 'err',
reason:
'response schema validation failed: stargazers_count must be a non-negative safe integer'
}
}
return {
kind: 'ok',
snapshot: makeSnapshot(count),
data: {}
}
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string }
async function callOnce(fetchImpl: typeof fetch): Promise<CallResponse> {
try {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
const res = await fetchImpl(GITHUB_REPO_API_URL, {
headers: { Accept: 'application/vnd.github.v3+json' }
})
if (!res.ok) return null
const data = await res.json()
return data.stargazers_count ?? null
if (res.ok) return { kind: 'ok', body: await res.json() }
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim()
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason }
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<GitHubStarsSnapshot | null> {
if (!snapshotUrl) {
return isGitHubStarsSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isGitHubStarsSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function makeSnapshot(stargazersCount: number): GitHubStarsSnapshot {
return {
fetchedAt: new Date().toISOString(),
repository: GITHUB_REPOSITORY,
stargazersCount
}
}
function readStargazerCount(data: unknown): number | null {
if (data === null || typeof data !== 'object') return null
const count = (data as { stargazers_count?: unknown }).stargazers_count
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
? count
: null
}
export function formatStarCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000
@@ -42,3 +149,10 @@ function readGitHubStarsOverride(): number | undefined {
return count
}
function getGitHubStarsCacheKey(options: FetchGitHubStarsOptions): string {
return JSON.stringify({
override: process.env.WEBSITE_GITHUB_STARS_OVERRIDE ?? '',
snapshotUrl: options.snapshotUrl?.href ?? ''
})
}