diff --git a/.github/workflows/publish-version-metadata.yml b/.github/workflows/publish-version-metadata.yml new file mode 100644 index 0000000000000000000000000000000000000000..3162f545ada0b8d84d1266941796f1f6825eb164 --- /dev/null +++ b/.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 diff --git a/scripts/generate-version-metadata.py b/scripts/generate-version-metadata.py new file mode 100755 index 0000000000000000000000000000000000000000..73127b514070210b8f7f31cd8de283aa86241f12 --- /dev/null +++ b/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 = """ + +
github.com/boldsoftware/shelley
+ + + +""" + 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() diff --git a/server/versioncheck.go b/server/versioncheck.go index 4ed6b349422b4fb68d87fb2d155ab6b75d296ef7..b97ee21bcb0689adc25c2750c7b5180006cafe25 100644 --- a/server/versioncheck.go +++ b/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_