Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/deploy-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,19 @@ jobs:
ORAMA_CLOUD_ENDPOINT: ${{ secrets.ORAMA_CLOUD_ENDPOINT }}
CLOUDFLARE_TURNSTILE_SITE_KEY: ${{ secrets.CLOUDFLARE_TURNSTILE_SITE_KEY }}
- run: bun run build:after
- name: Remove legacy builder-metrics Worker secrets
# Secrets persist across deploys and uploaded versions, so they are only synced on regular deploys.
- name: Sync Worker secrets
if: needs.plan.outputs.abtest != 'true'
working-directory: apps/web
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
run: |
bunx wrangler@4.126.0 secret delete SUPABASE_SERVICE_ROLE_KEY || true
bunx wrangler@4.126.0 secret delete BUILDER_DATABASE_URL || true
# Authenticated GitHub calls for /github-releases.json and sponsors; Cloudflare IPs are rate-limited without it.
printf '%s' "$PERSONAL_ACCESS_TOKEN" | bunx wrangler@4.126.0 secret put PERSONAL_ACCESS_TOKEN
- run: bun run deploy:web
if: needs.plan.outputs.abtest != 'true'
env:
Expand Down
53 changes: 27 additions & 26 deletions apps/web/src/lib/publicGithubReleases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,48 +17,49 @@ export type PublicGithubReleases = {
releases: PublicGithubRelease[]
}

type GithubReleaseRow = {
tag_name?: string
html_url?: string
published_at?: string | null
draft?: boolean
prerelease?: boolean
}

export type GithubReleasesOptions = {
token?: string
fetch?: typeof fetch
now?: Date
}

const entryPattern = /<entry>([\s\S]*?)<\/entry>/g
const linkPattern = /<link\b[^>]*\bhref="([^"]+\/releases\/tag\/[^"]+)"/
const updatedPattern = /<updated>([^<]+)<\/updated>/

// Parses a GitHub releases Atom feed. The feed is served by github.com, not the REST API,
// so it needs no token and is not subject to the shared 60 req/h unauthenticated API limit.
export function parseGithubReleasesAtom(repo: string, xml: string): PublicGithubRelease[] {
return [...xml.matchAll(entryPattern)].flatMap(([, entry]) => {
const url = entry.match(linkPattern)?.[1]?.replaceAll('&amp;', '&')
const updated = entry.match(updatedPattern)?.[1]
const publishedAt = updated && !Number.isNaN(Date.parse(updated)) ? new Date(updated).toISOString() : null
if (!url || !publishedAt) return []
const tag = decodeURIComponent(url.slice(url.lastIndexOf('/releases/tag/') + '/releases/tag/'.length))
return tag ? [{ repo, tag, url, publishedAt }] : []
})
}

export function mergeGithubReleases(lists: PublicGithubRelease[][], limit = GITHUB_RELEASE_LIMIT): PublicGithubRelease[] {
return lists
.flat()
export function normalizeGithubReleases(rows: Array<{ repo: string; data: GithubReleaseRow[] }>, limit = GITHUB_RELEASE_LIMIT): PublicGithubRelease[] {
return rows
.flatMap(({ repo, data }) =>
(Array.isArray(data) ? data : [])
.filter((release) => !release.draft && !release.prerelease && typeof release.published_at === 'string' && release.tag_name && release.html_url)
.map((release) => ({ repo, tag: release.tag_name as string, url: release.html_url as string, publishedAt: release.published_at as string })),
)
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
.slice(0, limit)
}

export async function getPublicGithubReleases(options: GithubReleasesOptions = {}): Promise<PublicGithubReleases> {
const fetchImpl = options.fetch ?? fetch
const lists = await Promise.all(
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'User-Agent': 'capgo-website',
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
}
const rows = await Promise.all(
GITHUB_RELEASE_REPOS.map(async (repo) => {
const response = await fetchImpl(`https://github.com/${repo}/releases.atom`, {
headers: { Accept: 'application/atom+xml', 'User-Agent': 'capgo-website' },
const response = await fetchImpl(`https://api.github.com/repos/${repo}/releases?per_page=${GITHUB_RELEASE_LIMIT}`, {
headers,
signal: AbortSignal.timeout(15_000),
})
if (!response.ok) throw new Error(`GitHub releases feed HTTP ${response.status} for ${repo}`)
return parseGithubReleasesAtom(repo, await response.text())
if (!response.ok) throw new Error(`GitHub releases HTTP ${response.status} for ${repo}`)
return { repo, data: (await response.json()) as GithubReleaseRow[] }
}),
)
const releases = mergeGithubReleases(lists)
const releases = normalizeGithubReleases(rows)
if (!releases.length) throw new Error('GitHub returned no published releases')
return { updated_at: (options.now ?? new Date()).toISOString(), releases }
}
16 changes: 14 additions & 2 deletions apps/web/src/worker/github-releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,20 @@ import { getPublicGithubReleases, GITHUB_RELEASES_CACHE_TTL_SECONDS, GITHUB_RELE
import { cachedJsonResponse } from './cached-json'
import type { BackgroundContext } from './types'

export async function handleGithubReleases(request: Request, ctx?: BackgroundContext): Promise<Response> {
return cachedJsonResponse(request, GITHUB_RELEASES_PATH, GITHUB_RELEASES_CACHE_TTL_SECONDS, () => getPublicGithubReleases(), 'GitHub releases are temporarily unavailable', ctx)
export interface GithubReleasesEnv {
PERSONAL_ACCESS_TOKEN?: string
}

export async function handleGithubReleases(request: Request, env: GithubReleasesEnv, ctx?: BackgroundContext): Promise<Response> {
const token = env.PERSONAL_ACCESS_TOKEN?.trim() || undefined
return cachedJsonResponse(
request,
GITHUB_RELEASES_PATH,
GITHUB_RELEASES_CACHE_TTL_SECONDS,
() => getPublicGithubReleases({ token }),
'GitHub releases are temporarily unavailable',
ctx,
)
}

export { GITHUB_RELEASES_PATH }
4 changes: 2 additions & 2 deletions apps/web/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ const routeDefinitions: Record<string, RouteDefinition> = {
},
[GITHUB_RELEASES_PATH]: {
methods: ['GET', 'HEAD'],
handle: async (request, _env, ctx) => {
const response = await handleGithubReleases(request, ctx)
handle: async (request, env, ctx) => {
const response = await handleGithubReleases(request, env, ctx)
if (request.method === 'HEAD') return new Response(null, { status: response.status, headers: response.headers })
return response
},
Expand Down
61 changes: 25 additions & 36 deletions apps/web/test/github-releases.test.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,44 @@
import { expect, test } from 'bun:test'
import { getPublicGithubReleases, GITHUB_RELEASE_REPOS, mergeGithubReleases, parseGithubReleasesAtom } from '../src/lib/publicGithubReleases.ts'
import { getPublicGithubReleases, GITHUB_RELEASE_REPOS, normalizeGithubReleases } from '../src/lib/publicGithubReleases.ts'

const entry = (repo, tag, updated) => `
<entry>
<id>tag:github.com,2008:Repository/1/${tag}</id>
<updated>${updated}</updated>
<link rel="alternate" type="text/html" href="https://github.com/${repo}/releases/tag/${tag}"/>
<title>${tag}</title>
</entry>`

const feed = (...entries) => `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><updated>2026-10-11T00:00:00Z</updated>${entries.join('')}</feed>`

test('parseGithubReleasesAtom reads tag, url and date from each entry', () => {
const releases = parseGithubReleasesAtom(
'Cap-go/capgo.app',
feed(entry('Cap-go/capgo.app', 'capgo-16.3.1', '2026-10-11T17:24:40Z'), entry('Cap-go/capgo.app', 'cli-8.77.0', 'not-a-date')),
)
expect(releases).toEqual([
{ repo: 'Cap-go/capgo.app', tag: 'capgo-16.3.1', url: 'https://github.com/Cap-go/capgo.app/releases/tag/capgo-16.3.1', publishedAt: '2026-10-11T17:24:40.000Z' },
])
})

test('mergeGithubReleases sorts newest first across repos and caps the list', () => {
const releases = mergeGithubReleases(
test('normalizeGithubReleases drops drafts and prereleases, sorts newest first, and caps the list', () => {
const releases = normalizeGithubReleases(
[
[
{ repo: 'a', tag: 'capgo-1.0.0', url: 'u1', publishedAt: '2026-10-01T00:00:00.000Z' },
{ repo: 'a', tag: 'cli-2.0.0', url: 'u2', publishedAt: '2026-10-05T00:00:00.000Z' },
],
[{ repo: 'b', tag: '8.0.0', url: 'u3', publishedAt: '2026-09-01T00:00:00.000Z' }],
{
repo: 'Cap-go/capgo.app',
data: [
{ tag_name: 'capgo-1.0.0', html_url: 'https://x/1', published_at: '2026-10-01T00:00:00Z' },
{ tag_name: 'capgo-0.9.0', html_url: 'https://x/0', published_at: '2026-09-01T00:00:00Z', draft: true },
{ tag_name: 'capgo-0.8.0', html_url: 'https://x/8', published_at: '2026-08-01T00:00:00Z', prerelease: true },
],
},
{
repo: 'Cap-go/capacitor-updater',
data: [
{ tag_name: 'cli-2.0.0', html_url: 'https://x/2', published_at: '2026-10-05T00:00:00Z' },
{ tag_name: 'cli-1.9.0', html_url: 'https://x/19', published_at: null },
],
},
],
2,
)
expect(releases.map((release) => release.tag)).toEqual(['cli-2.0.0', 'capgo-1.0.0'])
})

test('getPublicGithubReleases reads every repo feed without a token and returns an updated_at stamp', async () => {
test('getPublicGithubReleases queries every repo with the token and returns an updated_at stamp', async () => {
const calls = []
const fetchImpl = async (url, init) => {
calls.push({ url, auth: init.headers.Authorization })
const repo = url.replace('https://github.com/', '').replace('/releases.atom', '')
return new Response(feed(entry(repo, '1.0.0', '2026-10-01T00:00:00Z')), { status: 200 })
return new Response(JSON.stringify([{ tag_name: 't', html_url: 'u', published_at: '2026-10-01T00:00:00Z' }]), { status: 200 })
}
const result = await getPublicGithubReleases({ fetch: fetchImpl, now: new Date('2026-10-08T00:00:00Z') })
expect(calls.map((call) => call.url)).toEqual(GITHUB_RELEASE_REPOS.map((repo) => `https://github.com/${repo}/releases.atom`))
expect(calls.every((call) => call.auth === undefined)).toBe(true)
const result = await getPublicGithubReleases({ token: 'abc', fetch: fetchImpl, now: new Date('2026-10-08T00:00:00Z') })
expect(calls).toHaveLength(GITHUB_RELEASE_REPOS.length)
expect(calls.every((call) => call.auth === 'Bearer abc')).toBe(true)
expect(result.updated_at).toBe('2026-10-08T00:00:00.000Z')
expect(result.releases).toHaveLength(GITHUB_RELEASE_REPOS.length)
})

test('getPublicGithubReleases rejects when GitHub fails so the cache keeps the last good payload', async () => {
const fetchImpl = async () => new Response('rate limited', { status: 429 })
await expect(getPublicGithubReleases({ fetch: fetchImpl })).rejects.toThrow('HTTP 429')
const fetchImpl = async () => new Response('rate limited', { status: 403 })
await expect(getPublicGithubReleases({ fetch: fetchImpl })).rejects.toThrow('HTTP 403')
})
Loading