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;