diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 5e156ce..cd20db9 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -4,6 +4,12 @@ on:
push:
branches: [main]
paths: ["site/**", ".github/workflows/deploy-pages.yml"]
+ # Re-deploy (and refresh the cached release snapshot) when a release is
+ # published, and once a day to catch edits/deletions.
+ release:
+ types: [published]
+ schedule:
+ - cron: "0 6 * * *"
workflow_dispatch: {}
permissions:
@@ -30,6 +36,29 @@ jobs:
- run: npm ci
working-directory: site
+ # Fetch the latest release SERVER-SIDE with the workflow token and bake
+ # it into the site as a static file. Visitors then read this same-origin
+ # snapshot instead of hitting GitHub's anonymous API (60 req/hour/IP),
+ # which was returning 403s and showing the error fallback. Authenticated
+ # requests have a far higher limit, so this never rate-limits.
+ # If there's no published release (or the fetch fails) we leave the file
+ # absent and the site falls back to the live API at runtime.
+ - name: Cache latest release as a static file
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if curl -sf \
+ -H "Authorization: Bearer $GH_TOKEN" \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2022-11-28" \
+ "https://api.github.com/repos/${{ github.repository }}/releases/latest" \
+ -o site/public/latest-release.json; then
+ echo "Cached release: $(node -p "require('./site/public/latest-release.json').tag_name")"
+ else
+ echo "No published release or fetch failed; site will use the live API fallback."
+ rm -f site/public/latest-release.json
+ fi
+
- run: npm run build
working-directory: site
diff --git a/site/.gitignore b/site/.gitignore
index a547bf3..b54d88d 100644
--- a/site/.gitignore
+++ b/site/.gitignore
@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
+# Generated at deploy time by the workflow (see deploy-pages.yml).
+public/latest-release.json
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/site/src/components/Downloads.jsx b/site/src/components/Downloads.jsx
index d8d0913..9ec4f32 100644
--- a/site/src/components/Downloads.jsx
+++ b/site/src/components/Downloads.jsx
@@ -15,7 +15,7 @@ const OS_ICONS = {
}
export default function Downloads() {
- const { status, release } = useLatestRelease()
+ const { status, release, error } = useLatestRelease()
const visitorOS = useMemo(() => detectOS(), [])
return (
@@ -28,7 +28,7 @@ export default function Downloads() {
{status === 'loading' &&
}
- {status === 'error' &&
}
+ {status === 'error' &&
}
{status === 'success' && (
)}
@@ -46,13 +46,14 @@ function LoadingState() {
)
}
-function FallbackState() {
+function FallbackState({ error }) {
return (
Couldn't load the latest release automatically — GitHub's API limits
anonymous requests, or your connection blipped.
+ {error &&
({error})
}
diff --git a/site/src/hooks/useLatestRelease.js b/site/src/hooks/useLatestRelease.js
index 87e2681..765bd33 100644
--- a/site/src/hooks/useLatestRelease.js
+++ b/site/src/hooks/useLatestRelease.js
@@ -1,41 +1,83 @@
import { useEffect, useState } from 'react'
import { RELEASES_API } from '../lib/releases.js'
-// Fetches the latest GitHub release once on mount. GitHub's REST API
-// allows 60 unauthenticated requests per hour per IP — plenty for a
-// visitor loading this page a few times, but easy to exhaust while
-// developing locally with fast refresh. When that happens (or the
-// network fails, or the repo somehow has no releases yet) `status`
-// becomes 'error' and the caller should fall back to linking straight
-// at the GitHub Releases page instead of trying to render assets.
+// Build-time snapshot written by the deploy workflow (see
+// .github/workflows/deploy-pages.yml). Same-origin, so no CORS and no
+// rate limit — this is the primary source. BASE_URL is "/diskern/" in
+// production, "/" in local dev.
+const STATIC_RELEASE_URL = `${import.meta.env.BASE_URL}latest-release.json`
+
+// Fetches the latest release, preferring the static snapshot baked into
+// the deployed site and falling back to GitHub's live API.
+//
+// Why the snapshot first: GitHub's REST API allows only 60 unauthenticated
+// requests/hour PER IP, shared across everyone behind the same NAT. Calling
+// it on every page load meant a handful of visitors (or refreshes) from one
+// network exhausted the budget and everyone got the error fallback. The
+// deploy workflow fetches the release server-side with GITHUB_TOKEN (a much
+// higher, authenticated limit) and writes it to a static file, so normal
+// visitors never touch the rate-limited API at all.
+//
+// The live API remains a fallback for the cases the snapshot can't cover:
+// local dev (no file generated) and any deploy where generation failed.
export function useLatestRelease() {
const [status, setStatus] = useState('loading') // 'loading' | 'success' | 'error'
const [release, setRelease] = useState(null)
+ const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
- fetch(RELEASES_API, {
- headers: { Accept: 'application/vnd.github+json' },
- })
- .then((res) => {
- if (!res.ok) throw new Error(`GitHub API responded ${res.status}`)
- return res.json()
- })
- .then((data) => {
- if (cancelled) return
- setRelease(data)
- setStatus('success')
- })
- .catch(() => {
- if (cancelled) return
- setStatus('error')
- })
+ async function load() {
+ // 1. Static snapshot (same-origin, no rate limit).
+ try {
+ const res = await fetch(STATIC_RELEASE_URL, { cache: 'no-cache' })
+ // A missing file on GitHub Pages returns the SPA 404 shell, so
+ // guard on both res.ok and the content actually being release JSON.
+ if (res.ok) {
+ const data = await res.json()
+ if (data && (data.tag_name || data.assets)) {
+ if (!cancelled) {
+ setRelease(data)
+ setStatus('success')
+ }
+ return
+ }
+ }
+ } catch {
+ // Not JSON / not present — fall through to the live API.
+ }
+
+ // 2. Live GitHub API fallback (may be rate-limited).
+ try {
+ const res = await fetch(RELEASES_API, {
+ headers: { Accept: 'application/vnd.github+json' },
+ })
+ if (!res.ok) {
+ const limited = res.status === 403 || res.status === 429
+ throw new Error(
+ `GitHub API responded ${res.status}${limited ? ' (rate limit — 60 requests/hour per IP)' : ''}`,
+ )
+ }
+ const data = await res.json()
+ if (!cancelled) {
+ setRelease(data)
+ setStatus('success')
+ }
+ } catch (e) {
+ if (!cancelled) {
+ setError(e instanceof Error ? e.message : String(e))
+ setStatus('error')
+ }
+ }
+ }
+
+ load()
return () => {
cancelled = true
}
}, [])
- return { status, release }
+ return { status, release, error }
}
diff --git a/site/src/styles/index.css b/site/src/styles/index.css
index 7c6389b..59eaa0d 100644
--- a/site/src/styles/index.css
+++ b/site/src/styles/index.css
@@ -345,6 +345,12 @@ section {
text-decoration: underline;
}
+.downloads-fallback-detail {
+ font-size: 0.8rem;
+ opacity: 0.7;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
.downloads-skeleton {
display: flex;
flex-direction: column;