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
51 changes: 26 additions & 25 deletions cmd/san/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"bufio"
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 16 additions & 31 deletions cmd/san/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -75,31 +61,30 @@ 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 {
t.Fatal("expected error for 500 response, got nil")
}
}

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")
}
}

Expand Down
25 changes: 16 additions & 9 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 2 additions & 20 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down