shelley: add GitHub Pages for version metadata to avoid API rate limits.

Philip Zeyliger and Shelley created

Prompt: In a new worktree, have Shelley's build process also publish into a
static page hosted by GitHub the last release version metadata and the last
500 short commit Shas and their subjects. This will be used to have Shelley
check for updates instead of the GitHub api. Let me know what I'll need to
configure on GitHub to make gh pages work

- Add publish-version-metadata.yml workflow that runs after each release
- Add scripts/generate-version-metadata.py to generate release.json and commits.json
- Update versioncheck.go to use only the static GitHub Pages
- Simplify types: ReleaseInfo replaces GitHubRelease, remove GitHubCommit
- Remove all GitHub API code from version checking

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

.github/workflows/publish-version-metadata.yml |  46 +++
scripts/generate-version-metadata.py           | 110 ++++++++
server/versioncheck.go                         | 251 ++++++++++---------
server/versioncheck_test.go                    |  83 +++---
4 files changed, 328 insertions(+), 162 deletions(-)

Detailed changes

.github/workflows/publish-version-metadata.yml 🔗

@@ -0,0 +1,46 @@
+name: Publish Version Metadata
+
+on:
+  workflow_run:
+    workflows: ["Release"]
+    types:
+      - completed
+  workflow_dispatch:
+
+permissions:
+  contents: read
+  pages: write
+  id-token: write
+
+concurrency:
+  group: "pages"
+  cancel-in-progress: true
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Generate version metadata
+        run: python3 scripts/generate-version-metadata.py _site
+
+      - name: Upload artifact
+        uses: actions/upload-pages-artifact@v3
+        with:
+          path: _site
+
+  deploy:
+    environment:
+      name: github-pages
+      url: ${{ steps.deployment.outputs.page_url }}
+    runs-on: ubuntu-latest
+    needs: build
+    steps:
+      - name: Deploy to GitHub Pages
+        id: deployment
+        uses: actions/deploy-pages@v4

scripts/generate-version-metadata.py 🔗

@@ -0,0 +1,110 @@
+#!/usr/bin/env python3
+"""Generate version metadata for GitHub Pages.
+
+Generates release.json, commits.json, and index.html.
+"""
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+def generate_release_json(output_dir: Path) -> None:
+    """Generate release.json with latest release information."""
+    # Get latest tag - fail if none exists
+    result = subprocess.run(
+        ["git", "describe", "--tags", "--abbrev=0"],
+        capture_output=True,
+        text=True,
+    )
+    if result.returncode != 0:
+        print("ERROR: No tags found. Run this after creating a release.", file=sys.stderr)
+        sys.exit(1)
+
+    latest_tag = result.stdout.strip()
+    latest_commit = subprocess.check_output(
+        ["git", "rev-list", "-n", "1", latest_tag], text=True
+    ).strip()
+    latest_commit_short = latest_commit[:7]
+    latest_commit_time = subprocess.check_output(
+        ["git", "show", "-s", "--format=%cI", latest_commit], text=True
+    ).strip()
+    published_at = subprocess.check_output(
+        ["git", "show", "-s", "--format=%cI", latest_tag], text=True
+    ).strip()
+
+    version = latest_tag[1:] if latest_tag.startswith("v") else latest_tag
+
+    release_info = {
+        "tag_name": latest_tag,
+        "version": version,
+        "commit": latest_commit_short,
+        "commit_full": latest_commit,
+        "commit_time": latest_commit_time,
+        "published_at": published_at,
+        "download_urls": {
+            "darwin_amd64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_darwin_amd64",
+            "darwin_arm64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_darwin_arm64",
+            "linux_amd64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_linux_amd64",
+            "linux_arm64": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/shelley_linux_arm64",
+        },
+        "checksums_url": f"https://github.com/boldsoftware/shelley/releases/download/{latest_tag}/checksums.txt",
+    }
+
+    output_path = output_dir / "release.json"
+    with open(output_path, "w") as f:
+        json.dump(release_info, f, indent=2)
+    print(f"Generated {output_path}")
+
+
+def generate_commits_json(output_dir: Path, count: int = 500) -> None:
+    """Generate commits.json with recent commits."""
+    output = subprocess.check_output(
+        ["git", "log", f"--pretty=format:%h%x00%s", f"-{count}", "HEAD"],
+        text=True,
+    )
+
+    commits = []
+    for line in output.strip().split("\n"):
+        if "\x00" in line:
+            sha, subject = line.split("\x00", 1)
+            commits.append({"sha": sha, "subject": subject})
+
+    output_path = output_dir / "commits.json"
+    with open(output_path, "w") as f:
+        json.dump(commits, f, indent=2)
+    print(f"Generated {output_path} with {len(commits)} commits")
+
+
+def generate_index_html(output_dir: Path) -> None:
+    """Generate index.html."""
+    html = """<!DOCTYPE html>
+<html>
+<head><title>Shelley</title></head>
+<body>
+<p><a href="https://github.com/boldsoftware/shelley">github.com/boldsoftware/shelley</a></p>
+<ul>
+<li><a href="release.json">release.json</a></li>
+<li><a href="commits.json">commits.json</a></li>
+</ul>
+</body>
+</html>
+"""
+    output_path = output_dir / "index.html"
+    with open(output_path, "w") as f:
+        f.write(html)
+    print(f"Generated {output_path}")
+
+
+def main() -> None:
+    output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("_site")
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    generate_release_json(output_dir)
+    generate_commits_json(output_dir)
+    generate_index_html(output_dir)
+
+
+if __name__ == "__main__":
+    main()

server/versioncheck.go 🔗

@@ -15,7 +15,6 @@ import (
 	"os"
 	"os/exec"
 	"runtime"
-	"sort"
 	"strings"
 	"sync"
 	"time"
@@ -37,22 +36,22 @@ type VersionChecker struct {
 
 // VersionInfo contains version check results.
 type VersionInfo struct {
-	CurrentVersion      string         `json:"current_version"`
-	CurrentTag          string         `json:"current_tag,omitempty"`
-	CurrentCommit       string         `json:"current_commit,omitempty"`
-	CurrentCommitTime   string         `json:"current_commit_time,omitempty"`
-	LatestVersion       string         `json:"latest_version,omitempty"`
-	LatestTag           string         `json:"latest_tag,omitempty"`
-	PublishedAt         time.Time      `json:"published_at,omitempty"`
-	HasUpdate           bool           `json:"has_update"`    // True if minor version is newer (for showing upgrade button)
-	ShouldNotify        bool           `json:"should_notify"` // True if should show red dot (newer + 5 days old)
-	DownloadURL         string         `json:"download_url,omitempty"`
-	ExecutablePath      string         `json:"executable_path,omitempty"`
-	Commits             []CommitInfo   `json:"commits,omitempty"`
-	CheckedAt           time.Time      `json:"checked_at"`
-	Error               string         `json:"error,omitempty"`
-	RunningUnderSystemd bool           `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd)
-	ReleaseInfo         *GitHubRelease `json:"-"`                     // Internal, not exposed to JSON
+	CurrentVersion      string       `json:"current_version"`
+	CurrentTag          string       `json:"current_tag,omitempty"`
+	CurrentCommit       string       `json:"current_commit,omitempty"`
+	CurrentCommitTime   string       `json:"current_commit_time,omitempty"`
+	LatestVersion       string       `json:"latest_version,omitempty"`
+	LatestTag           string       `json:"latest_tag,omitempty"`
+	PublishedAt         time.Time    `json:"published_at,omitempty"`
+	HasUpdate           bool         `json:"has_update"`    // True if minor version is newer (for showing upgrade button)
+	ShouldNotify        bool         `json:"should_notify"` // True if should show red dot (newer + 5 days old)
+	DownloadURL         string       `json:"download_url,omitempty"`
+	ExecutablePath      string       `json:"executable_path,omitempty"`
+	Commits             []CommitInfo `json:"commits,omitempty"`
+	CheckedAt           time.Time    `json:"checked_at"`
+	Error               string       `json:"error,omitempty"`
+	RunningUnderSystemd bool         `json:"running_under_systemd"` // True if INVOCATION_ID env var is set (systemd)
+	ReleaseInfo         *ReleaseInfo `json:"-"`                     // Internal, not exposed to JSON
 }
 
 // CommitInfo represents a commit in the changelog.
@@ -63,29 +62,30 @@ type CommitInfo struct {
 	Date    time.Time `json:"date"`
 }
 
-// GitHubRelease represents a GitHub release from the API.
-type GitHubRelease struct {
-	TagName     string    `json:"tag_name"`
-	Name        string    `json:"name"`
-	PublishedAt time.Time `json:"published_at"`
-	Assets      []struct {
-		Name               string `json:"name"`
-		BrowserDownloadURL string `json:"browser_download_url"`
-	} `json:"assets"`
+// ReleaseInfo represents release metadata.
+type ReleaseInfo struct {
+	TagName      string            `json:"tag_name"`
+	Version      string            `json:"version"`
+	Commit       string            `json:"commit"`
+	CommitFull   string            `json:"commit_full"`
+	CommitTime   string            `json:"commit_time"`
+	PublishedAt  string            `json:"published_at"`
+	DownloadURLs map[string]string `json:"download_urls"`
+	ChecksumsURL string            `json:"checksums_url"`
 }
 
-// GitHubCommit represents a commit from the GitHub API.
-type GitHubCommit struct {
-	SHA    string `json:"sha"`
-	Commit struct {
-		Message string `json:"message"`
-		Author  struct {
-			Name string    `json:"name"`
-			Date time.Time `json:"date"`
-		} `json:"author"`
-	} `json:"commit"`
+// StaticCommitInfo represents a commit from commits.json.
+type StaticCommitInfo struct {
+	SHA     string `json:"sha"`
+	Subject string `json:"subject"`
 }
 
+const (
+	// staticMetadataURL is the base URL for version metadata on GitHub Pages.
+	// This avoids GitHub API rate limits.
+	staticMetadataURL = "https://boldsoftware.github.io/shelley"
+)
+
 // NewVersionChecker creates a new version checker.
 func NewVersionChecker() *VersionChecker {
 	skipCheck := os.Getenv("SHELLEY_SKIP_VERSION_CHECK") == "true"
@@ -138,7 +138,7 @@ func (vc *VersionChecker) Check(ctx context.Context, forceRefresh bool) (*Versio
 	return info, nil
 }
 
-// fetchVersionInfo fetches the latest release info from GitHub.
+// fetchVersionInfo fetches the latest release info from GitHub Pages.
 func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, error) {
 	currentInfo := version.GetInfo()
 	execPath, _ := os.Executable()
@@ -152,7 +152,7 @@ func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, e
 		RunningUnderSystemd: os.Getenv("INVOCATION_ID") != "",
 	}
 
-	// Fetch latest release
+	// Fetch latest release from static metadata
 	latestRelease, err := vc.fetchLatestRelease(ctx)
 	if err != nil {
 		return nil, fmt.Errorf("failed to fetch latest release: %w", err)
@@ -160,25 +160,29 @@ func (vc *VersionChecker) fetchVersionInfo(ctx context.Context) (*VersionInfo, e
 
 	info.LatestTag = latestRelease.TagName
 	info.LatestVersion = latestRelease.TagName
-	info.PublishedAt = latestRelease.PublishedAt
 	info.ReleaseInfo = latestRelease
 
+	// Parse the published_at time
+	if publishedAt, err := time.Parse(time.RFC3339, latestRelease.PublishedAt); err == nil {
+		info.PublishedAt = publishedAt
+	}
+
 	// Find the download URL for the current platform
 	info.DownloadURL = vc.findDownloadURL(latestRelease)
 
 	// Check if latest has a newer minor version
 	info.HasUpdate = vc.isNewerMinor(currentInfo.Tag, latestRelease.TagName)
 
-	// For ShouldNotify, we need to check if the versions are 5+ days apart
-	// Fetch the current version's release to compare dates
-	if info.HasUpdate && currentInfo.Tag != "" {
-		currentRelease, err := vc.fetchRelease(ctx, currentInfo.Tag)
-		if err == nil && currentRelease != nil {
+	// For ShouldNotify, compare commit times if we have an update
+	if info.HasUpdate && currentInfo.CommitTime != "" {
+		currentTime, err1 := time.Parse(time.RFC3339, currentInfo.CommitTime)
+		latestTime, err2 := time.Parse(time.RFC3339, latestRelease.CommitTime)
+		if err1 == nil && err2 == nil {
 			// Show notification if the latest release is 5+ days newer than current
-			timeBetween := latestRelease.PublishedAt.Sub(currentRelease.PublishedAt)
+			timeBetween := latestTime.Sub(currentTime)
 			info.ShouldNotify = timeBetween >= 5*24*time.Hour
 		} else {
-			// Can't fetch current release info, just notify if there's an update
+			// Can't parse times, just notify if there's an update
 			info.ShouldNotify = true
 		}
 	}
@@ -192,14 +196,12 @@ func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latest
 		return nil, nil
 	}
 
-	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/compare/%s...%s",
-		vc.githubOwner, vc.githubRepo, currentTag, latestTag)
+	url := staticMetadataURL + "/commits.json"
 
 	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
 	if err != nil {
 		return nil, err
 	}
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
 	req.Header.Set("User-Agent", "Shelley-VersionChecker")
 
 	resp, err := http.DefaultClient.Do(req)
@@ -209,88 +211,103 @@ func (vc *VersionChecker) FetchChangelog(ctx context.Context, currentTag, latest
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+		return nil, fmt.Errorf("static commits returned status %d", resp.StatusCode)
 	}
 
-	var compareResp struct {
-		Commits []GitHubCommit `json:"commits"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&compareResp); err != nil {
+	var staticCommits []StaticCommitInfo
+	if err := json.NewDecoder(resp.Body).Decode(&staticCommits); err != nil {
 		return nil, err
 	}
 
+	// Extract short SHAs from tags (tags are v0.X.YSHA where SHA is octal-encoded)
+	currentSHA := extractSHAFromTag(currentTag)
+	latestSHA := extractSHAFromTag(latestTag)
+
+	if currentSHA == "" || latestSHA == "" {
+		return nil, fmt.Errorf("could not extract SHAs from tags")
+	}
+
+	// Find the range of commits between current and latest
 	var commits []CommitInfo
-	for _, c := range compareResp.Commits {
-		// Get first line of commit message
-		message := c.Commit.Message
-		if idx := indexOf(message, '\n'); idx != -1 {
-			message = message[:idx]
+	var foundLatest, foundCurrent bool
+
+	for _, c := range staticCommits {
+		if c.SHA == latestSHA {
+			foundLatest = true
+		}
+		if foundLatest && !foundCurrent {
+			commits = append(commits, CommitInfo{
+				SHA:     c.SHA,
+				Message: c.Subject,
+			})
+		}
+		if c.SHA == currentSHA {
+			foundCurrent = true
+			break
 		}
-		commits = append(commits, CommitInfo{
-			SHA:     c.SHA[:7],
-			Message: message,
-			Author:  c.Commit.Author.Name,
-			Date:    c.Commit.Author.Date,
-		})
 	}
 
-	// Sort commits by date, newest first
-	sort.Slice(commits, func(i, j int) bool {
-		return commits[i].Date.After(commits[j].Date)
-	})
+	// If we didn't find both SHAs, the commits might be too old (outside 500 range)
+	if !foundLatest || !foundCurrent {
+		return nil, fmt.Errorf("commits not found in static list")
+	}
+
+	// Remove the current commit itself from the list (we want commits after current)
+	if len(commits) > 0 && commits[len(commits)-1].SHA == currentSHA {
+		commits = commits[:len(commits)-1]
+	}
 
 	return commits, nil
 }
 
-func indexOf(s string, c byte) int {
-	for i := 0; i < len(s); i++ {
-		if s[i] == c {
-			return i
-		}
+// extractSHAFromTag extracts the short commit SHA from a version tag.
+// Tags are formatted as v0.COUNT.9OCTAL where OCTAL is the SHA in octal.
+func extractSHAFromTag(tag string) string {
+	// Tag format: v0.178.9XXXXX where XXXXX is octal-encoded 6-char hex SHA
+	if len(tag) < 3 || tag[0] != 'v' {
+		return ""
 	}
-	return -1
-}
-
-// fetchRelease fetches a specific release by tag from GitHub.
-func (vc *VersionChecker) fetchRelease(ctx context.Context, tag string) (*GitHubRelease, error) {
-	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s",
-		vc.githubOwner, vc.githubRepo, tag)
 
-	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
-	if err != nil {
-		return nil, err
+	// Find the last dot
+	lastDot := -1
+	for i := len(tag) - 1; i >= 0; i-- {
+		if tag[i] == '.' {
+			lastDot = i
+			break
+		}
 	}
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
-	req.Header.Set("User-Agent", "Shelley-VersionChecker")
-
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return nil, err
+	if lastDot == -1 {
+		return ""
 	}
-	defer resp.Body.Close()
 
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+	// Extract the patch part (9XXXXX)
+	patch := tag[lastDot+1:]
+	if len(patch) < 2 || patch[0] != '9' {
+		return ""
 	}
 
-	var release GitHubRelease
-	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
-		return nil, err
+	// Parse the octal number after '9'
+	octal := patch[1:]
+	var hexVal uint64
+	for _, c := range octal {
+		if c < '0' || c > '7' {
+			return ""
+		}
+		hexVal = hexVal*8 + uint64(c-'0')
 	}
 
-	return &release, nil
+	// Convert back to 6-char hex SHA (short SHA)
+	return fmt.Sprintf("%06x", hexVal)
 }
 
-// fetchLatestRelease fetches the latest release from GitHub.
-func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubRelease, error) {
-	url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest",
-		vc.githubOwner, vc.githubRepo)
+// fetchLatestRelease fetches the latest release info from GitHub Pages.
+func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*ReleaseInfo, error) {
+	url := staticMetadataURL + "/release.json"
 
 	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
 	if err != nil {
 		return nil, err
 	}
-	req.Header.Set("Accept", "application/vnd.github.v3+json")
 	req.Header.Set("User-Agent", "Shelley-VersionChecker")
 
 	resp, err := http.DefaultClient.Do(req)
@@ -300,10 +317,10 @@ func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubReleas
 	defer resp.Body.Close()
 
 	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
+		return nil, fmt.Errorf("failed to fetch release info: status %d", resp.StatusCode)
 	}
 
-	var release GitHubRelease
+	var release ReleaseInfo
 	if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
 		return nil, err
 	}
@@ -312,16 +329,11 @@ func (vc *VersionChecker) fetchLatestRelease(ctx context.Context) (*GitHubReleas
 }
 
 // findDownloadURL finds the appropriate download URL for the current platform.
-func (vc *VersionChecker) findDownloadURL(release *GitHubRelease) string {
-	// Build expected asset name: shelley_<os>_<arch>
-	expectedName := fmt.Sprintf("shelley_%s_%s", runtime.GOOS, runtime.GOARCH)
-
-	for _, asset := range release.Assets {
-		if asset.Name == expectedName {
-			return asset.BrowserDownloadURL
-		}
+func (vc *VersionChecker) findDownloadURL(release *ReleaseInfo) string {
+	key := fmt.Sprintf("%s_%s", runtime.GOOS, runtime.GOARCH)
+	if url, ok := release.DownloadURLs[key]; ok {
+		return url
 	}
-
 	return ""
 }
 
@@ -524,17 +536,10 @@ func (vc *VersionChecker) doSudoUpgrade(binaryData []byte) error {
 }
 
 // fetchExpectedChecksum downloads checksums.txt and extracts the expected checksum for our binary.
-func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *GitHubRelease) (string, error) {
-	// Find checksums.txt URL
-	var checksumURL string
-	for _, asset := range release.Assets {
-		if asset.Name == "checksums.txt" {
-			checksumURL = asset.BrowserDownloadURL
-			break
-		}
-	}
+func (vc *VersionChecker) fetchExpectedChecksum(ctx context.Context, release *ReleaseInfo) (string, error) {
+	checksumURL := release.ChecksumsURL
 	if checksumURL == "" {
-		return "", fmt.Errorf("checksums.txt not found in release")
+		return "", fmt.Errorf("checksums.txt URL not found in release")
 	}
 
 	// Download checksums.txt

server/versioncheck_test.go 🔗

@@ -12,6 +12,36 @@ import (
 	"time"
 )
 
+func TestExtractSHAFromTag(t *testing.T) {
+	tests := []struct {
+		tag      string
+		expected string
+	}{
+		// Tag format: v0.COUNT.9OCTAL where OCTAL is the SHA in octal
+		// For example, 6-char hex SHA "abc123" (hex) = 0xabc123 = 11256099 (decimal)
+		// In octal: 52740443
+		{"v0.178.952740443", "abc123"}, // SHA abc123 in octal is 52740443
+		{"v0.178.933471105", "6e7245"}, // Real release tag
+		{"v0.1.90", "000000"},          // SHA 0
+		{"", ""},
+		{"invalid", ""},
+		{"v", ""},
+		{"v0", ""},
+		{"v0.1", ""},
+		{"v0.1.0", ""},  // No '9' prefix
+		{"v0.1.8x", ""}, // Invalid octal digit
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.tag, func(t *testing.T) {
+			result := extractSHAFromTag(tt.tag)
+			if result != tt.expected {
+				t.Errorf("extractSHAFromTag(%q) = %q, want %q", tt.tag, result, tt.expected)
+			}
+		})
+	}
+}
+
 func TestParseMinorVersion(t *testing.T) {
 	tests := []struct {
 		tag      string
@@ -113,14 +143,14 @@ func TestVersionCheckerCache(t *testing.T) {
 	callCount := 0
 	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		callCount++
-		release := GitHubRelease{
+		release := ReleaseInfo{
 			TagName:     "v0.10.0",
-			Name:        "Release v0.10.0",
-			PublishedAt: time.Now().Add(-10 * 24 * time.Hour),
-			Assets: []struct {
-				Name               string `json:"name"`
-				BrowserDownloadURL string `json:"browser_download_url"`
-			}{},
+			Version:     "0.10.0",
+			PublishedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339),
+			DownloadURLs: map[string]string{
+				"linux_amd64":  "https://example.com/linux_amd64",
+				"darwin_arm64": "https://example.com/darwin_arm64",
+			},
 		}
 		json.NewEncoder(w).Encode(release)
 	}))
@@ -138,7 +168,7 @@ func TestVersionCheckerCache(t *testing.T) {
 
 	// First call - should not use cache
 	_, err := vc.Check(ctx, false)
-	// Will fail because we're not actually calling GitHub, but that's OK for this test
+	// Will fail because we're not actually calling the static site, but that's OK for this test
 	// The important thing is that it tried to fetch
 
 	// Second call immediately after - should use cache if first succeeded
@@ -153,16 +183,13 @@ func TestVersionCheckerCache(t *testing.T) {
 func TestFindDownloadURL(t *testing.T) {
 	vc := &VersionChecker{}
 
-	release := &GitHubRelease{
+	release := &ReleaseInfo{
 		TagName: "v0.1.0",
-		Assets: []struct {
-			Name               string `json:"name"`
-			BrowserDownloadURL string `json:"browser_download_url"`
-		}{
-			{Name: "shelley_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"},
-			{Name: "shelley_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"},
-			{Name: "shelley_darwin_amd64", BrowserDownloadURL: "https://example.com/darwin_amd64"},
-			{Name: "shelley_darwin_arm64", BrowserDownloadURL: "https://example.com/darwin_arm64"},
+		DownloadURLs: map[string]string{
+			"linux_amd64":  "https://example.com/linux_amd64",
+			"linux_arm64":  "https://example.com/linux_arm64",
+			"darwin_amd64": "https://example.com/darwin_amd64",
+			"darwin_arm64": "https://example.com/darwin_arm64",
 		},
 	}
 
@@ -174,28 +201,6 @@ func TestFindDownloadURL(t *testing.T) {
 	}
 }
 
-func TestIndexOf(t *testing.T) {
-	tests := []struct {
-		s        string
-		c        byte
-		expected int
-	}{
-		{"hello\nworld", '\n', 5},
-		{"hello", '\n', -1},
-		{"", '\n', -1},
-		{"\n", '\n', 0},
-	}
-
-	for _, tt := range tests {
-		t.Run(tt.s, func(t *testing.T) {
-			result := indexOf(tt.s, tt.c)
-			if result != tt.expected {
-				t.Errorf("indexOf(%q, %q) = %d, want %d", tt.s, tt.c, result, tt.expected)
-			}
-		})
-	}
-}
-
 func TestIsPermissionError(t *testing.T) {
 	tests := []struct {
 		name     string