147 lines
4.3 KiB
Go
147 lines
4.3 KiB
Go
// Package selfupdate checks whether a newer sherlock release is
|
|
// available and reports it. The source of truth is the git tags on the
|
|
// public sherlock repo, read through Gitea's REST API (no auth — the
|
|
// repo is public).
|
|
//
|
|
// Versions are git tags of the form `vMAJOR.MINOR.PATCH`. The binary's
|
|
// own version is baked in at build time via -ldflags "-X main.Version".
|
|
// A build that still reads the sentinel dev version ("0.0.0-dev") is
|
|
// treated as "not a release" and never nags about updates.
|
|
package selfupdate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"golang.org/x/mod/semver"
|
|
)
|
|
|
|
// DevVersion is the sentinel main.Version of an unversioned local
|
|
// build. CheckForUpdate is a no-op for it.
|
|
const DevVersion = "0.0.0-dev"
|
|
|
|
// defaultTagsAPI is the Gitea tags endpoint for the public sherlock
|
|
// repo. Overridable via $SHERLOCK_UPDATE_TAGS_URL for forks/tests.
|
|
const defaultTagsAPI = "https://gitea.alexandru.macocian.me/api/v1/repos/amacocian/sherlock/tags"
|
|
|
|
// DefaultRepoURL is the public clone URL the self-updater builds from.
|
|
// Overridable via $SHERLOCK_UPDATE_REPO_URL.
|
|
const DefaultRepoURL = "https://gitea.alexandru.macocian.me/amacocian/sherlock"
|
|
|
|
// EnvRepoURL overrides the clone URL (forks, tests, mirrors).
|
|
const EnvRepoURL = "SHERLOCK_UPDATE_REPO_URL"
|
|
|
|
// RepoURL returns the configured clone URL.
|
|
func RepoURL() string {
|
|
if u := os.Getenv(EnvRepoURL); u != "" {
|
|
return u
|
|
}
|
|
return DefaultRepoURL
|
|
}
|
|
|
|
// EnvTagsURL overrides the tags API URL (forks, tests, air-gapped).
|
|
const EnvTagsURL = "SHERLOCK_UPDATE_TAGS_URL"
|
|
|
|
// EnvDisable, when set to a non-empty value, disables the passive
|
|
// update check entirely.
|
|
const EnvDisable = "SHERLOCK_NO_UPDATE_CHECK"
|
|
|
|
// tagsURL returns the configured tags endpoint.
|
|
func tagsURL() string {
|
|
if u := os.Getenv(EnvTagsURL); u != "" {
|
|
return u
|
|
}
|
|
return defaultTagsAPI
|
|
}
|
|
|
|
// normalize coerces a raw version string into the canonical, comparable
|
|
// `vX.Y.Z` form semver expects. Accepts both "0.1.0" and "v0.1.0".
|
|
// Returns "" if the input isn't valid semver.
|
|
func normalize(v string) string {
|
|
if v == "" {
|
|
return ""
|
|
}
|
|
if v[0] != 'v' {
|
|
v = "v" + v
|
|
}
|
|
if !semver.IsValid(v) {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
type tag struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// LatestTag fetches the highest semver tag from the repo. It returns
|
|
// the canonical `vX.Y.Z` string, or "" with a nil error when the repo
|
|
// has no valid version tags yet.
|
|
func LatestTag(ctx context.Context, client *http.Client) (string, error) {
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 5 * time.Second}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tagsURL(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("selfupdate: tags API returned HTTP %d", resp.StatusCode)
|
|
}
|
|
var tags []tag
|
|
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
|
|
return "", fmt.Errorf("selfupdate: decode tags: %w", err)
|
|
}
|
|
latest := ""
|
|
for _, t := range tags {
|
|
v := normalize(t.Name)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if latest == "" || semver.Compare(v, latest) > 0 {
|
|
latest = v
|
|
}
|
|
}
|
|
return latest, nil
|
|
}
|
|
|
|
// Result is the outcome of a CheckForUpdate call.
|
|
type Result struct {
|
|
Current string // the running binary's version, normalized (e.g. "v0.1.0")
|
|
Latest string // the latest published tag, normalized (e.g. "v0.2.0")
|
|
Available bool // true iff Latest is a strictly newer valid release
|
|
}
|
|
|
|
// CheckForUpdate compares the running version against the latest tag.
|
|
// It never errors out the caller's flow for "expected" cases (dev
|
|
// build, no tags, network down): those yield Available=false. A non-nil
|
|
// error is returned only for genuinely unexpected failures the caller
|
|
// may want to surface in an explicit `sherlock update`.
|
|
func CheckForUpdate(ctx context.Context, current string, client *http.Client) (Result, error) {
|
|
res := Result{Current: normalize(current)}
|
|
if current == DevVersion || res.Current == "" {
|
|
// Unversioned/dev build — don't nag.
|
|
return res, nil
|
|
}
|
|
latest, err := LatestTag(ctx, client)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
res.Latest = latest
|
|
if latest == "" {
|
|
return res, nil
|
|
}
|
|
res.Available = semver.Compare(latest, res.Current) > 0
|
|
return res, nil
|
|
}
|