diff --git a/cmd/san/update.go b/cmd/san/update.go index 6bc0975e..32747b71 100644 --- a/cmd/san/update.go +++ b/cmd/san/update.go @@ -6,7 +6,6 @@ import ( "bufio" "compress/gzip" "context" - "encoding/json" "errors" "fmt" "io" @@ -20,8 +19,8 @@ import ( ) var ( - githubAPI = "https://api.github.com/repos/genai-io/san/releases/latest" - httpClient = http.DefaultClient + githubLatestRelease = "https://github.com/genai-io/san/releases/latest" + httpClient = http.DefaultClient ) // releaseInfo represents the GitHub API response for a release. @@ -66,16 +65,7 @@ func runSelfUpdate(ctx context.Context) error { archiveExt = ".zip" } assetName := fmt.Sprintf("san_%s_%s%s", runtime.GOOS, goArch(runtime.GOARCH), archiveExt) - var downloadURL string - for _, a := range latest.Assets { - if a.Name == assetName { - downloadURL = a.BrowserDownloadURL - break - } - } - if downloadURL == "" { - return fmt.Errorf("no release asset found for %s/%s", runtime.GOOS, runtime.GOARCH) - } + downloadURL := fmt.Sprintf("https://github.com/genai-io/san/releases/download/v%s/%s", latestVersion, assetName) // Find current binary path exe, err := os.Executable() @@ -168,32 +158,43 @@ func runSelfUpdate(ctx context.Context) error { return nil } -// fetchLatestRelease fetches the latest release info from GitHub. +// fetchLatestRelease fetches the latest release tag from GitHub +// without hitting the rate-limited API. func fetchLatestRelease(ctx context.Context) (*releaseInfo, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodHead, githubLatestRelease, nil) if err != nil { return nil, err } - req.Header.Set("Accept", "application/json") - resp, err := httpClient.Do(req) + // Don't follow redirects — we need the Location header + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("GitHub API returned %s", resp.Status) + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + return nil, fmt.Errorf("unexpected response from GitHub: %s", resp.Status) } - var release releaseInfo - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, err + location := resp.Header.Get("Location") + if location == "" { + return nil, fmt.Errorf("missing Location header in redirect response") } - if release.TagName == "" { - return nil, fmt.Errorf("unexpected response from GitHub API") + + // Location: https://github.com/genai-io/san/releases/tag/v1.21.4 + version := strings.TrimPrefix(location, "https://github.com/genai-io/san/releases/tag/v") + if version == location { + return nil, fmt.Errorf("unexpected Location header: %s", location) } - return &release, nil + + return &releaseInfo{TagName: "v" + version}, nil } // downloadWithProgress downloads a file from url to dest with a terminal progress bar. diff --git a/cmd/san/update_test.go b/cmd/san/update_test.go index abf7114d..7f63832d 100644 --- a/cmd/san/update_test.go +++ b/cmd/san/update_test.go @@ -6,7 +6,6 @@ import ( "bytes" "compress/gzip" "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -35,27 +34,17 @@ func TestGoArch(t *testing.T) { func TestFetchLatestRelease(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { + if r.Method != http.MethodHead { t.Errorf("unexpected method: %s", r.Method) } - resp := releaseInfo{ - TagName: "v1.21.0", - Assets: []struct { - Name string `json:"name"` - BrowserDownloadURL string `json:"browser_download_url"` - }{ - {Name: "san_darwin_amd64.tar.gz", BrowserDownloadURL: "https://example.com/san_darwin_amd64.tar.gz"}, - {Name: "san_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/san_linux_amd64.tar.gz"}, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + w.Header().Set("Location", "https://github.com/genai-io/san/releases/tag/v1.21.0") + w.WriteHeader(http.StatusFound) })) defer srv.Close() - oldURL := githubAPI - githubAPI = srv.URL - defer func() { githubAPI = oldURL }() + oldURL := githubLatestRelease + githubLatestRelease = srv.URL + defer func() { githubLatestRelease = oldURL }() release, err := fetchLatestRelease(context.Background()) if err != nil { @@ -64,9 +53,6 @@ func TestFetchLatestRelease(t *testing.T) { if release.TagName != "v1.21.0" { t.Errorf("TagName = %q, want %q", release.TagName, "v1.21.0") } - if len(release.Assets) != 2 { - t.Errorf("len(Assets) = %d, want 2", len(release.Assets)) - } } func TestFetchLatestRelease_HTTPError(t *testing.T) { @@ -75,9 +61,9 @@ func TestFetchLatestRelease_HTTPError(t *testing.T) { })) defer srv.Close() - oldURL := githubAPI - githubAPI = srv.URL - defer func() { githubAPI = oldURL }() + oldURL := githubLatestRelease + githubLatestRelease = srv.URL + defer func() { githubLatestRelease = oldURL }() _, err := fetchLatestRelease(context.Background()) if err == nil { @@ -85,21 +71,20 @@ func TestFetchLatestRelease_HTTPError(t *testing.T) { } } -func TestFetchLatestRelease_EmptyTag(t *testing.T) { +func TestFetchLatestRelease_InvalidRedirect(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := releaseInfo{TagName: ""} - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) + w.Header().Set("Location", "https://example.com/something-else") + w.WriteHeader(http.StatusFound) })) defer srv.Close() - oldURL := githubAPI - githubAPI = srv.URL - defer func() { githubAPI = oldURL }() + oldURL := githubLatestRelease + githubLatestRelease = srv.URL + defer func() { githubLatestRelease = oldURL }() _, err := fetchLatestRelease(context.Background()) if err == nil { - t.Fatal("expected error for empty tag, got nil") + t.Fatal("expected error for invalid Location header, got nil") } } diff --git a/install.ps1 b/install.ps1 index 9861d2a3..d4d91324 100644 --- a/install.ps1 +++ b/install.ps1 @@ -43,18 +43,25 @@ function Get-Arch { } function Get-LatestVersion { - $headers = @{ 'User-Agent' = 'san-installer' } - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $headers - return ($release.tag_name -replace '^v', '') + $req = [System.Net.WebRequest]::Create("https://github.com/$Repo/releases/latest") + $req.Method = 'HEAD' + $req.AllowAutoRedirect = $false + try { + $resp = $req.GetResponse() + if ([int]$resp.StatusCode -eq 302 -or [int]$resp.StatusCode -eq 301) { + $location = $resp.Headers['Location'] + if ($location -match '/tag/v([^/]+)') { + return $matches[1] + } + } + } finally { + if ($resp) { $resp.Close() } + } + Fail "Failed to get latest version" } function Get-DownloadUrl($version, $arch) { - $asset = "${Binary}_windows_${arch}.zip" - $headers = @{ 'User-Agent' = 'san-installer' } - $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/tags/v$version" -Headers $headers - $match = $release.assets | Where-Object { $_.name -eq $asset } | Select-Object -First 1 - if (-not $match) { Fail "Release asset $asset not found for v$version" } - return $match.browser_download_url + return "https://github.com/$Repo/releases/download/v${version}/${Binary}_windows_${arch}.zip" } function Get-InstalledVersion { diff --git a/install.sh b/install.sh index f034d598..63161c1f 100644 --- a/install.sh +++ b/install.sh @@ -49,30 +49,12 @@ detect_platform() { } get_latest_version() { - curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/' + curl -fsSI "https://github.com/${REPO}/releases/latest" | grep -i "^location:" | sed -E 's/.*\/tag\/v([^/]*).*/\1/' | tr -d '\r' } get_download_url() { local version="$1" - local asset_name="san_${OS}_${ARCH}.tar.gz" - local api_url="https://api.github.com/repos/${REPO}/releases/tags/v${version}" - - curl -fsSL "$api_url" | awk -v asset="$asset_name" ' - /"name":/ { - if ($0 ~ "\"" asset "\"") { - found=1 - } - } - found && /"browser_download_url":/ { - line=$0 - sub(/^.*"browser_download_url":[[:space:]]*"/, "", line) - sub(/".*$/, "", line) - if (line != "" && !printed) { - print line - printed=1 - } - } - ' + echo "https://github.com/${REPO}/releases/download/v${version}/san_${OS}_${ARCH}.tar.gz" } do_install() {