mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
CheckForUpdate used to trust any cache entry less than 12h old whose CurrentAt matched the running binary. Problem: if a user installs v0.0.72 at 16:05 (cache: up-to-date) and a v0.0.73 release ships at 18:27, relaunching zot at 18:29 hits the fresh cache and never calls the github api \u2014 the update banner stays hidden until either 12h pass or the binary is rebuilt. Noticed in the wild: v0.0.73 latest, zot 0.0.72 running, no banner. Refined logic: only short-circuit on a cached entry when it already reports Available=true. If the cache says "up to date" we let the flow fall through to fetchLatestRelease and reconcile. The api call is a single ~4s timeout request; cheap enough to do on every up-to-date launch, and the common "cache already shows available" path still skips the network entirely. Also: manually cleared /Users/pat/Library/Application Support/zot/update-check.json on my local box so the next launch sees the new v0.0.73 release without waiting; that's user state, not something to commit.
224 lines
6.7 KiB
Go
224 lines
6.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// updateCheckTTL is how often we hit the GitHub API to look for a new
|
|
// release. Half a day is frequent enough to notice the same day a
|
|
// release ships without spamming the API on every zot launch.
|
|
const updateCheckTTL = 12 * time.Hour
|
|
|
|
// updateCheckFile is the on-disk cache, keyed to the current binary
|
|
// version. Writes a JSON blob with the last observed latest tag and
|
|
// when we checked, so we can skip the network call on most launches.
|
|
const updateCheckFile = "update-check.json"
|
|
|
|
// githubReleasesAPI is the REST endpoint we query. Using the API (not
|
|
// the HTML redirect) because the JSON response is stable and small.
|
|
const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
|
|
|
|
// UpdateInfo describes the result of an update check. Zero-value means
|
|
// "no update available, no error, don't show anything".
|
|
type UpdateInfo struct {
|
|
Current string // e.g. "0.0.4"
|
|
Latest string // e.g. "0.0.5"
|
|
Available bool // true when latest > current
|
|
URL string // release page url for the changelog link
|
|
}
|
|
|
|
// updateCache is the on-disk structure written to $ZOT_HOME.
|
|
type updateCache struct {
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
// The version that was current when we last checked. Invalidates
|
|
// the cache if the binary itself has been updated since.
|
|
CurrentAt string `json:"current_at"`
|
|
Latest string `json:"latest"`
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// CheckForUpdate returns info about a newer release, using a cached
|
|
// result when one is fresh enough. Designed to be called at tui
|
|
// startup and rendered as a dismissible banner.
|
|
//
|
|
// Always returns a usable UpdateInfo (zero-value on error). The
|
|
// banner renderer skips the display when Available is false, so a
|
|
// network failure silently no-ops; we never block startup on this.
|
|
func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo {
|
|
// Dev builds ("0.0.0") never have an update to offer. Skip.
|
|
if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
|
|
return UpdateInfo{}
|
|
}
|
|
|
|
cachePath := filepath.Join(zotHome, updateCheckFile)
|
|
if c, ok := readUpdateCache(cachePath); ok {
|
|
// Cache is fresh and tracks the same binary version.
|
|
// Additional guard: only trust the cache when it already
|
|
// reports an available update. If the cache says "up to
|
|
// date" (Latest <= Current) we re-check anyway so a
|
|
// release published after the last launch is picked up
|
|
// without waiting out the full TTL. The API call is a
|
|
// single 4s request; cheap to do on every up-to-date
|
|
// launch, and skips entirely for the common "installed a
|
|
// new version, cache reports update available" path.
|
|
if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
|
|
info := buildInfo(currentVersion, c.Latest, c.URL)
|
|
if info.Available {
|
|
return info
|
|
}
|
|
// fall through to re-check
|
|
}
|
|
}
|
|
|
|
latest, url, err := fetchLatestRelease(ctx)
|
|
if err != nil {
|
|
// Network or auth failure (common while the repo is private
|
|
// and no GITHUB_TOKEN is set). Silent no-op; we'll try again
|
|
// after the TTL on the next launch.
|
|
return UpdateInfo{}
|
|
}
|
|
|
|
_ = writeUpdateCache(cachePath, updateCache{
|
|
CheckedAt: time.Now().UTC(),
|
|
CurrentAt: currentVersion,
|
|
Latest: latest,
|
|
URL: url,
|
|
})
|
|
|
|
return buildInfo(currentVersion, latest, url)
|
|
}
|
|
|
|
// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
|
|
// result to the returned channel, and never blocks startup. The
|
|
// channel is always closed; receivers should `ok`-check.
|
|
func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo {
|
|
ch := make(chan UpdateInfo, 1)
|
|
go func() {
|
|
defer close(ch)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
ch <- CheckForUpdate(ctx, zotHome, currentVersion)
|
|
}()
|
|
return ch
|
|
}
|
|
|
|
func buildInfo(current, latest, url string) UpdateInfo {
|
|
info := UpdateInfo{
|
|
Current: current,
|
|
Latest: strings.TrimPrefix(latest, "v"),
|
|
URL: url,
|
|
}
|
|
info.Available = versionLess(info.Current, info.Latest)
|
|
return info
|
|
}
|
|
|
|
// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
|
|
// Non-numeric components compare as zero, which is fine for zot's
|
|
// x.y.z-only scheme.
|
|
func versionLess(a, b string) bool {
|
|
as := splitVersion(a)
|
|
bs := splitVersion(b)
|
|
for i := 0; i < 3; i++ {
|
|
av, bv := 0, 0
|
|
if i < len(as) {
|
|
av = as[i]
|
|
}
|
|
if i < len(bs) {
|
|
bv = bs[i]
|
|
}
|
|
if av != bv {
|
|
return av < bv
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func splitVersion(s string) []int {
|
|
s = strings.TrimPrefix(s, "v")
|
|
// Strip build-metadata suffix like "(abc1234, 2026-04-18)".
|
|
if i := strings.IndexAny(s, " ("); i > 0 {
|
|
s = s[:i]
|
|
}
|
|
parts := strings.Split(s, ".")
|
|
out := make([]int, 0, len(parts))
|
|
for _, p := range parts {
|
|
n, _ := strconv.Atoi(p)
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fetchLatestRelease queries the GitHub API for the latest published
|
|
// release. Honours $GITHUB_TOKEN for private repos.
|
|
func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
req.Header.Set("accept", "application/vnd.github+json")
|
|
req.Header.Set("x-github-api-version", "2022-11-28")
|
|
if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
|
|
req.Header.Set("authorization", "Bearer "+tok)
|
|
}
|
|
|
|
client := &http.Client{Timeout: 4 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return "", "", fmt.Errorf("github api %d", resp.StatusCode)
|
|
}
|
|
|
|
var body struct {
|
|
TagName string `json:"tag_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return "", "", err
|
|
}
|
|
return body.TagName, body.HTMLURL, nil
|
|
}
|
|
|
|
// readUpdateCache loads the last check result. Returns ok=false on
|
|
// any error (missing file, bad json) so callers just treat it as a
|
|
// cache miss and re-fetch.
|
|
func readUpdateCache(path string) (updateCache, bool) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return updateCache{}, false
|
|
}
|
|
var c updateCache
|
|
if err := json.Unmarshal(b, &c); err != nil {
|
|
return updateCache{}, false
|
|
}
|
|
return c, true
|
|
}
|
|
|
|
func writeUpdateCache(path string, c updateCache) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, b, 0o600)
|
|
}
|
|
|
|
// cachedLatest, cachedLatestOnce: used elsewhere in the binary if we
|
|
// want a synchronous read without triggering a network call.
|
|
var (
|
|
cachedLatest string
|
|
cachedLatestOnce sync.Once
|
|
)
|