@@ -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()
@@ -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
@@ -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