1package agent
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "sync"
13 "time"
14)
15
16// updateCheckTTL is how often we hit the GitHub API to look for a new
17// release. Half a day is frequent enough to notice the same day a
18// release ships without spamming the API on every zot launch.
19const updateCheckTTL = 12 * time.Hour
20
21// updateCheckFile is the on-disk cache, keyed to the current binary
22// version. Writes a JSON blob with the last observed latest tag and
23// when we checked, so we can skip the network call on most launches.
24const updateCheckFile = "update-check.json"
25
26// githubReleasesAPI is the REST endpoint we query. Using the API (not
27// the HTML redirect) because the JSON response is stable and small.
28const githubReleasesAPI = "https://api.github.com/repos/patriceckhart/zot/releases/latest"
29
30// UpdateInfo describes the result of an update check. Zero-value means
31// "no update available, no error, don't show anything".
32type UpdateInfo struct {
33 Current string // e.g. "0.0.4"
34 Latest string // e.g. "0.0.5"
35 Available bool // true when latest > current
36 URL string // release page url for the changelog link
37}
38
39// updateCache is the on-disk structure written to $ZOT_HOME.
40type updateCache struct {
41 CheckedAt time.Time `json:"checked_at"`
42 // The version that was current when we last checked. Invalidates
43 // the cache if the binary itself has been updated since.
44 CurrentAt string `json:"current_at"`
45 Latest string `json:"latest"`
46 URL string `json:"url"`
47}
48
49// CheckForUpdate returns info about a newer release, using a cached
50// result when one is fresh enough. Designed to be called at tui
51// startup and rendered as a dismissible banner.
52//
53// Always returns a usable UpdateInfo (zero-value on error). The
54// banner renderer skips the display when Available is false, so a
55// network failure silently no-ops; we never block startup on this.
56func CheckForUpdate(ctx context.Context, zotHome, currentVersion string) UpdateInfo {
57 // Dev builds ("0.0.0") never have an update to offer. Skip.
58 if currentVersion == "" || currentVersion == "dev" || currentVersion == "0.0.0" {
59 return UpdateInfo{}
60 }
61
62 cachePath := filepath.Join(zotHome, updateCheckFile)
63 if c, ok := readUpdateCache(cachePath); ok {
64 // Cache is fresh and tracks the same binary version.
65 // Additional guard: only trust the cache when it already
66 // reports an available update. If the cache says "up to
67 // date" (Latest <= Current) we re-check anyway so a
68 // release published after the last launch is picked up
69 // without waiting out the full TTL. The API call is a
70 // single 4s request; cheap to do on every up-to-date
71 // launch, and skips entirely for the common "installed a
72 // new version, cache reports update available" path.
73 if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
74 info := buildInfo(currentVersion, c.Latest, c.URL)
75 if info.Available {
76 return info
77 }
78 // fall through to re-check
79 }
80 }
81
82 latest, url, err := fetchLatestRelease(ctx)
83 if err != nil {
84 // Network or auth failure (common while the repo is private
85 // and no GITHUB_TOKEN is set). Silent no-op; we'll try again
86 // after the TTL on the next launch.
87 return UpdateInfo{}
88 }
89
90 _ = writeUpdateCache(cachePath, updateCache{
91 CheckedAt: time.Now().UTC(),
92 CurrentAt: currentVersion,
93 Latest: latest,
94 URL: url,
95 })
96
97 return buildInfo(currentVersion, latest, url)
98}
99
100// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
101// result to the returned channel, and never blocks startup. The
102// channel is always closed; receivers should `ok`-check.
103func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo {
104 ch := make(chan UpdateInfo, 1)
105 go func() {
106 defer close(ch)
107 ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
108 defer cancel()
109 ch <- CheckForUpdate(ctx, zotHome, currentVersion)
110 }()
111 return ch
112}
113
114func buildInfo(current, latest, url string) UpdateInfo {
115 info := UpdateInfo{
116 Current: current,
117 Latest: strings.TrimPrefix(latest, "v"),
118 URL: url,
119 }
120 info.Available = versionLess(info.Current, info.Latest)
121 return info
122}
123
124// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
125// Non-numeric components compare as zero, which is fine for zot's
126// x.y.z-only scheme.
127func versionLess(a, b string) bool {
128 as := splitVersion(a)
129 bs := splitVersion(b)
130 for i := 0; i < 3; i++ {
131 av, bv := 0, 0
132 if i < len(as) {
133 av = as[i]
134 }
135 if i < len(bs) {
136 bv = bs[i]
137 }
138 if av != bv {
139 return av < bv
140 }
141 }
142 return false
143}
144
145func splitVersion(s string) []int {
146 s = strings.TrimPrefix(s, "v")
147 // Strip build-metadata suffix like "(abc1234, 2026-04-18)".
148 if i := strings.IndexAny(s, " ("); i > 0 {
149 s = s[:i]
150 }
151 parts := strings.Split(s, ".")
152 out := make([]int, 0, len(parts))
153 for _, p := range parts {
154 n, _ := strconv.Atoi(p)
155 out = append(out, n)
156 }
157 return out
158}
159
160// fetchLatestRelease queries the GitHub API for the latest published
161// release. Honours $GITHUB_TOKEN for private repos.
162func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
163 req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
164 if err != nil {
165 return "", "", err
166 }
167 req.Header.Set("accept", "application/vnd.github+json")
168 req.Header.Set("x-github-api-version", "2022-11-28")
169 if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
170 req.Header.Set("authorization", "Bearer "+tok)
171 }
172
173 client := &http.Client{Timeout: 4 * time.Second}
174 resp, err := client.Do(req)
175 if err != nil {
176 return "", "", err
177 }
178 defer resp.Body.Close()
179 if resp.StatusCode != 200 {
180 return "", "", fmt.Errorf("github api %d", resp.StatusCode)
181 }
182
183 var body struct {
184 TagName string `json:"tag_name"`
185 HTMLURL string `json:"html_url"`
186 }
187 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
188 return "", "", err
189 }
190 return body.TagName, body.HTMLURL, nil
191}
192
193// readUpdateCache loads the last check result. Returns ok=false on
194// any error (missing file, bad json) so callers just treat it as a
195// cache miss and re-fetch.
196func readUpdateCache(path string) (updateCache, bool) {
197 b, err := os.ReadFile(path)
198 if err != nil {
199 return updateCache{}, false
200 }
201 var c updateCache
202 if err := json.Unmarshal(b, &c); err != nil {
203 return updateCache{}, false
204 }
205 return c, true
206}
207
208func writeUpdateCache(path string, c updateCache) error {
209 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
210 return err
211 }
212 b, err := json.MarshalIndent(c, "", " ")
213 if err != nil {
214 return err
215 }
216 return os.WriteFile(path, b, 0o600)
217}
218
219// cachedLatest, cachedLatestOnce: used elsewhere in the binary if we
220// want a synchronous read without triggering a network call.
221var (
222 cachedLatest string
223 cachedLatestOnce sync.Once
224)