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
29 changes: 29 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions site/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions site/src/components/Downloads.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -28,7 +28,7 @@ export default function Downloads() {

<div className="downloads-card">
{status === 'loading' && <LoadingState />}
{status === 'error' && <FallbackState />}
{status === 'error' && <FallbackState error={error} />}
{status === 'success' && (
<ReleaseState release={release} visitorOS={visitorOS} />
)}
Expand All @@ -46,13 +46,14 @@ function LoadingState() {
)
}

function FallbackState() {
function FallbackState({ error }) {
return (
<div className="downloads-fallback">
<p>
Couldn't load the latest release automatically — GitHub's API limits
anonymous requests, or your connection blipped.
</p>
{error && <p className="downloads-fallback-detail">({error})</p>}
<p>
<a href={RELEASES_PAGE} target="_blank" rel="noopener noreferrer">
<DownloadIcon style={{ width: 16, height: 16, display: 'inline', verticalAlign: '-3px' }} />
Expand Down
90 changes: 66 additions & 24 deletions site/src/hooks/useLatestRelease.js
Original file line number Diff line number Diff line change
@@ -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 }
}
6 changes: 6 additions & 0 deletions site/src/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading