Files
amacocian 1c2108dd2b
Release / tag (push) Successful in 2s
CI / build (push) Successful in 3m5s
Fix fmt
2026-06-13 13:40:24 +02:00

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
}