package update import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" ) // Release mirrors the subset of the GitHub releases API response that the // self-updater consumes. type Release struct { TagName string `json:"tag_name"` Name string `json:"name"` Body string `json:"body"` Assets []Asset `json:"assets"` } // Asset is a single downloadable artifact attached to a release. type Asset struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` Size int64 `json:"size"` } const githubAPI = "https://api.github.com" // LatestRelease fetches the newest published release for repo (form // "owner/name"). A non-empty token is sent as a Bearer header, which is // required for private repositories and lifts the unauthenticated rate limit. func LatestRelease(ctx context.Context, repo, token string) (*Release, error) { repo = strings.TrimSpace(repo) if repo == "" { return nil, fmt.Errorf("update: repository not configured (set --repo or VOCAT_REPO)") } if strings.Count(repo, "/") != 1 { return nil, fmt.Errorf("update: invalid repository %q (expected owner/name)", repo) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI+"/repos/"+repo+"/releases/latest", nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/vnd.github+json") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("update: fetch latest release: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusForbidden { // The releases API returns 403 (not 404) when rate-limited. body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return nil, fmt.Errorf("update: GitHub API rejected the request (likely rate-limited): %s", strings.TrimSpace(string(body))) } if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("update: no published release found for %s", repo) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("update: GitHub API returned %s", resp.Status) } var release Release if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, fmt.Errorf("update: decode release JSON: %w", err) } return &release, nil } // downloadAsset streams a release asset into dst, honoring the request context. // The token is applied for consistency with the API call (GitHub release assets // redirect to a pre-signed S3 URL; the token is dropped on redirect, which is // the expected public-CDN flow). func downloadAsset(ctx context.Context, url, token string, dst io.Writer) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("Accept", "application/octet-stream") if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("update: download asset: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("update: asset download returned %s", resp.Status) } if _, err := io.Copy(dst, resp.Body); err != nil { return fmt.Errorf("update: read asset body: %w", err) } return nil }