v0.0.51 c610a3a

update.go RawHistoryFolder

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		if time.Since(c.CheckedAt) < updateCheckTTL && c.CurrentAt == currentVersion {
66			return buildInfo(currentVersion, c.Latest, c.URL)
67		}
68	}
69
70	latest, url, err := fetchLatestRelease(ctx)
71	if err != nil {
72		// Network or auth failure (common while the repo is private
73		// and no GITHUB_TOKEN is set). Silent no-op; we'll try again
74		// after the TTL on the next launch.
75		return UpdateInfo{}
76	}
77
78	_ = writeUpdateCache(cachePath, updateCache{
79		CheckedAt: time.Now().UTC(),
80		CurrentAt: currentVersion,
81		Latest:    latest,
82		URL:       url,
83	})
84
85	return buildInfo(currentVersion, latest, url)
86}
87
88// CheckForUpdateAsync runs CheckForUpdate in a goroutine, delivers the
89// result to the returned channel, and never blocks startup. The
90// channel is always closed; receivers should `ok`-check.
91func CheckForUpdateAsync(zotHome, currentVersion string) <-chan UpdateInfo {
92	ch := make(chan UpdateInfo, 1)
93	go func() {
94		defer close(ch)
95		ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
96		defer cancel()
97		ch <- CheckForUpdate(ctx, zotHome, currentVersion)
98	}()
99	return ch
100}
101
102func buildInfo(current, latest, url string) UpdateInfo {
103	info := UpdateInfo{
104		Current: current,
105		Latest:  strings.TrimPrefix(latest, "v"),
106		URL:     url,
107	}
108	info.Available = versionLess(info.Current, info.Latest)
109	return info
110}
111
112// versionLess returns a < b for dotted semver-ish tags like "0.0.4".
113// Non-numeric components compare as zero, which is fine for zot's
114// x.y.z-only scheme.
115func versionLess(a, b string) bool {
116	as := splitVersion(a)
117	bs := splitVersion(b)
118	for i := 0; i < 3; i++ {
119		av, bv := 0, 0
120		if i < len(as) {
121			av = as[i]
122		}
123		if i < len(bs) {
124			bv = bs[i]
125		}
126		if av != bv {
127			return av < bv
128		}
129	}
130	return false
131}
132
133func splitVersion(s string) []int {
134	s = strings.TrimPrefix(s, "v")
135	// Strip build-metadata suffix like "(abc1234, 2026-04-18)".
136	if i := strings.IndexAny(s, " ("); i > 0 {
137		s = s[:i]
138	}
139	parts := strings.Split(s, ".")
140	out := make([]int, 0, len(parts))
141	for _, p := range parts {
142		n, _ := strconv.Atoi(p)
143		out = append(out, n)
144	}
145	return out
146}
147
148// fetchLatestRelease queries the GitHub API for the latest published
149// release. Honours $GITHUB_TOKEN for private repos.
150func fetchLatestRelease(ctx context.Context) (tag, url string, err error) {
151	req, err := http.NewRequestWithContext(ctx, "GET", githubReleasesAPI, nil)
152	if err != nil {
153		return "", "", err
154	}
155	req.Header.Set("accept", "application/vnd.github+json")
156	req.Header.Set("x-github-api-version", "2022-11-28")
157	if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
158		req.Header.Set("authorization", "Bearer "+tok)
159	}
160
161	client := &http.Client{Timeout: 4 * time.Second}
162	resp, err := client.Do(req)
163	if err != nil {
164		return "", "", err
165	}
166	defer resp.Body.Close()
167	if resp.StatusCode != 200 {
168		return "", "", fmt.Errorf("github api %d", resp.StatusCode)
169	}
170
171	var body struct {
172		TagName string `json:"tag_name"`
173		HTMLURL string `json:"html_url"`
174	}
175	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
176		return "", "", err
177	}
178	return body.TagName, body.HTMLURL, nil
179}
180
181// readUpdateCache loads the last check result. Returns ok=false on
182// any error (missing file, bad json) so callers just treat it as a
183// cache miss and re-fetch.
184func readUpdateCache(path string) (updateCache, bool) {
185	b, err := os.ReadFile(path)
186	if err != nil {
187		return updateCache{}, false
188	}
189	var c updateCache
190	if err := json.Unmarshal(b, &c); err != nil {
191		return updateCache{}, false
192	}
193	return c, true
194}
195
196func writeUpdateCache(path string, c updateCache) error {
197	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
198		return err
199	}
200	b, err := json.MarshalIndent(c, "", "  ")
201	if err != nil {
202		return err
203	}
204	return os.WriteFile(path, b, 0o600)
205}
206
207// cachedLatest, cachedLatestOnce: used elsewhere in the binary if we
208// want a synchronous read without triggering a network call.
209var (
210	cachedLatest     string
211	cachedLatestOnce sync.Once
212)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close