// Package updatecheck looks up the latest RelSpec release on the project's // Gitea instance and compares it against the running version. package updatecheck import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" ) // DefaultAPIURL is the Gitea endpoint that returns the latest release. const DefaultAPIURL = "https://git.warky.dev/api/v1/repos/wdevs/relspecgo/releases/latest" // Asset is a downloadable file attached to a release. type Asset struct { Name string `json:"name"` URL string `json:"browser_download_url"` } // Release is the subset of the Gitea release payload that is needed. type Release struct { Tag string `json:"tag_name"` URL string `json:"html_url"` Assets []Asset `json:"assets"` } // Latest fetches the latest release from apiURL. A nil client uses a client // with a 10 second timeout. func Latest(ctx context.Context, client *http.Client, apiURL string) (*Release, error) { if client == nil { client = &http.Client{Timeout: 10 * time.Second} } req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("checking for updates: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("checking for updates: unexpected status %s", resp.Status) } var rel Release if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return nil, fmt.Errorf("decoding release: %w", err) } if rel.Tag == "" { return nil, fmt.Errorf("release has no tag") } return &rel, nil } // FindAsset returns the first asset with the given name. func (r *Release) FindAsset(name string) (Asset, bool) { for _, a := range r.Assets { if a.Name == name { return a, true } } return Asset{}, false } // IsNewer reports whether latest is a higher version than current. Versions // that are not dotted numbers (for example "dev" or a commit hash) are never // considered outdated, so development builds are not nagged. func IsNewer(current, latest string) bool { cur, ok := parseVersion(current) if !ok { return false } lat, ok := parseVersion(latest) if !ok { return false } for i := range cur { if lat[i] != cur[i] { return lat[i] > cur[i] } } return false } // parseVersion parses "v1.2.3" style versions into three numeric parts. // Missing parts are zero; any pre-release or build suffix is ignored. func parseVersion(v string) ([3]int, bool) { var out [3]int v = strings.TrimPrefix(strings.TrimSpace(v), "v") if i := strings.IndexAny(v, "-+ "); i >= 0 { v = v[:i] } if v == "" { return out, false } parts := strings.Split(v, ".") if len(parts) > 3 { return out, false } for i, p := range parts { n, err := strconv.Atoi(p) if err != nil || n < 0 { return out, false } out[i] = n } return out, true }