detached f47996b

agent: add 'zot update' subcommand for in-place self-update

Adds a CLI subcommand that downloads the latest GitHub release for
the current GOOS/GOARCH, verifies its sha256 against checksums.txt,
extracts the archive, and atomically replaces the running binary.

  zot update           install the latest release
  zot update --check   show whether one is available, install nothing
  zot update --help    usage

Dispatch follows the same router shape as runBotCommand /
runExtCommand in cli.go. Asset naming stays in sync with the
archives.name_template in .goreleaser.yaml (zot_<ver>_<os>_<arch>).
Reuses fetchLatestRelease + versionLess from update.go so the
"what's latest" answer is identical to the in-TUI banner.

Refuses to operate on dev builds (version 0.0.0) since the version
comparison is meaningless and we'd happily downgrade a freshly
compiled local build back to whatever ships on GitHub. $GITHUB_TOKEN
is honoured so private-repo releases work.

Unix: atomic os.Rename in place (the kernel keeps the running
binary's inode alive until exit). Windows: rename current aside
to .old, drop the new exe in, leave the .old for next-update
cleanup since the running process has it locked.
commit
f47996bc8230e48f4543f7ac31529aa0439a369c
author
patriceckhart · · 2026-05-12 19:27 UTC
parent
43da5e5
signature
unsigned
refs
tag: v0.1.78
3 files · +430 -0
M internal/agent/args.go +4 -0
@@ -323,6 +323,10 @@ func PrintHelp(version string) {
323323 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
324324 row{"zot ext help", "show all extension subcommands"},
325325 )
326+ section("self-update",
327+ row{"zot update", "download and install the latest release"},
328+ row{"zot update --check", "show whether a new release is available"},
329+ )
326330 section("telegram",
327331 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},
328332 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},
@@ -323,6 +323,10 @@ func PrintHelp(version string) {
323 row{"zot --ext ./path/to/ext", "load an extension for this run only"},323 row{"zot --ext ./path/to/ext", "load an extension for this run only"},
324 row{"zot ext help", "show all extension subcommands"},324 row{"zot ext help", "show all extension subcommands"},
325 )325 )
326+ section("self-update",
327+ row{"zot update", "download and install the latest release"},
328+ row{"zot update --check", "show whether a new release is available"},
329+ )
326 section("telegram",330 section("telegram",
327 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},331 row{"zot telegram-bot setup", "configure a telegram bot (from BotFather)"},
328 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},332 row{"zot telegram-bot run", "foreground bridge (ctrl+c to stop)"},
M internal/agent/cli.go +3 -0
@@ -172,6 +172,9 @@ func Run(rawArgs []string, version string) error {
172172 if handled, err := runExtCommand(rawArgs); handled {
173173 return err
174174 }
175+ if handled, err := runUpdateCommand(rawArgs, version); handled {
176+ return err
177+ }
175178 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
176179 // spawn the binary with a clean argv. Strip the leading 'rpc'
177180 // token and let the rest flow through the normal arg parser.
@@ -172,6 +172,9 @@ func Run(rawArgs []string, version string) error {
172 if handled, err := runExtCommand(rawArgs); handled {172 if handled, err := runExtCommand(rawArgs); handled {
173 return err173 return err
174 }174 }
175+ if handled, err := runUpdateCommand(rawArgs, version); handled {
176+ return err
177+ }
175 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can178 // `zot rpc` is shorthand for `zot --rpc` so third-party apps can
176 // spawn the binary with a clean argv. Strip the leading 'rpc'179 // spawn the binary with a clean argv. Strip the leading 'rpc'
177 // token and let the rest flow through the normal arg parser.180 // token and let the rest flow through the normal arg parser.
A internal/agent/updatecmd.go +423 -0
@@ -0,0 +1,423 @@
1+package agent
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/hex"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "runtime"
15+ "strings"
16+ "time"
17+)
18+
19+// runUpdateCommand dispatches `zot update`. Returns (handled=true, err)
20+// if rawArgs starts with "update"; otherwise (handled=false, nil) so
21+// the main router falls through to the regular flag parser. Mirrors
22+// the shape of runBotCommand / runExtCommand on purpose: identical
23+// dispatch shape keeps the router in cli.go uniform.
24+//
25+// The command:
26+//
27+// 1. Resolves the latest release tag via the GitHub API (same code
28+// path the in-TUI update banner uses, so we never disagree about
29+// "what is latest").
30+// 2. Picks the asset matching the current GOOS/GOARCH using the
31+// name template defined in .goreleaser.yaml.
32+// 3. Downloads checksums.txt and the asset to a temp directory.
33+// 4. Verifies the asset's sha256 against checksums.txt.
34+// 5. Extracts the zot binary from the archive.
35+// 6. Atomically replaces the running binary with the new one.
36+//
37+// Refuses to operate on dev builds (version == "0.0.0") because there
38+// is no meaningful "is newer" comparison and we'd happily downgrade a
39+// freshly-compiled local binary back to whatever's on GitHub.
40+func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) {
41+ if len(rawArgs) == 0 || rawArgs[0] != "update" {
42+ return false, nil
43+ }
44+ // Accept --help/-h for parity with the rest of the CLI.
45+ for _, a := range rawArgs[1:] {
46+ switch a {
47+ case "-h", "--help", "help":
48+ printUpdateHelp()
49+ return true, nil
50+ case "--check":
51+ return true, runUpdateCheck(version)
52+ default:
53+ printUpdateHelp()
54+ return true, fmt.Errorf("unknown update flag: %s", a)
55+ }
56+ }
57+ return true, runUpdate(version)
58+}
59+
60+func printUpdateHelp() {
61+ fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release
62+
63+usage:
64+ zot update download and install the newest release
65+ zot update --check show what update is available; install nothing
66+ zot update --help show this help
67+
68+notes:
69+ * The binary must be writable by the current user. On a system-wide
70+ install (e.g. /usr/local/bin/zot owned by root) re-run with sudo.
71+ * Dev builds (version 0.0.0) are refused — they typically come from
72+ 'go install' or a local 'make build' and shouldn't be silently
73+ replaced with a release binary.
74+ * Honours $GITHUB_TOKEN if set, so private-repo releases work.`)
75+}
76+
77+// runUpdateCheck just prints what would happen without doing the
78+// download. Useful as a sanity probe and as something the user can
79+// pipe into scripts.
80+func runUpdateCheck(version string) error {
81+ if version == "" || version == "dev" || version == "0.0.0" {
82+ fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled")
83+ return nil
84+ }
85+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
86+ defer cancel()
87+ tag, url, err := fetchLatestRelease(ctx)
88+ if err != nil {
89+ return fmt.Errorf("query latest release: %w", err)
90+ }
91+ latest := strings.TrimPrefix(tag, "v")
92+ current := versionOnly(version)
93+ if !versionLess(current, latest) {
94+ fmt.Printf("zot %s is up to date (latest: %s)\n", current, latest)
95+ return nil
96+ }
97+ fmt.Printf("zot %s -> %s available\n release: %s\n run 'zot update' to install\n", current, latest, url)
98+ return nil
99+}
100+
101+// runUpdate is the meat of `zot update`.
102+func runUpdate(version string) error {
103+ if version == "" || version == "dev" || version == "0.0.0" {
104+ return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases")
105+ }
106+ current := versionOnly(version)
107+
108+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109+ defer cancel()
110+
111+ fmt.Println("zot update: querying latest release…")
112+ tag, releaseURL, err := fetchLatestRelease(ctx)
113+ if err != nil {
114+ return fmt.Errorf("query latest release: %w", err)
115+ }
116+ latest := strings.TrimPrefix(tag, "v")
117+
118+ if !versionLess(current, latest) {
119+ fmt.Printf("zot %s is already up to date.\n", current)
120+ return nil
121+ }
122+ fmt.Printf("zot update: %s -> %s\n", current, latest)
123+ fmt.Printf("zot update: release page %s\n", releaseURL)
124+
125+ // Pick the archive matching this platform.
126+ assetName, archiveFmt, err := releaseAssetName(latest)
127+ if err != nil {
128+ return err
129+ }
130+ fmt.Printf("zot update: target asset %s\n", assetName)
131+
132+ // We assume the standard GoReleaser layout: assets live under
133+ // https://github.com/<owner>/<repo>/releases/download/<tag>/<file>
134+ base := strings.TrimSuffix(releaseURL, "/")
135+ // releaseURL points at /releases/tag/<tag>; flip it to /releases/download/<tag>
136+ base = strings.Replace(base, "/releases/tag/", "/releases/download/", 1)
137+
138+ assetURL := base + "/" + assetName
139+ sumsURL := base + "/checksums.txt"
140+
141+ tmp, err := os.MkdirTemp("", "zot-update-")
142+ if err != nil {
143+ return fmt.Errorf("create temp dir: %w", err)
144+ }
145+ // Best effort: remove the temp dir at the end. If extraction
146+ // fails midway we leave it behind on purpose for diagnosis;
147+ // users can clear /tmp themselves.
148+ defer func() { _ = os.RemoveAll(tmp) }()
149+
150+ fmt.Println("zot update: downloading checksums.txt…")
151+ sumsPath := filepath.Join(tmp, "checksums.txt")
152+ if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
153+ return fmt.Errorf("download checksums: %w", err)
154+ }
155+ wantSum, err := lookupChecksum(sumsPath, assetName)
156+ if err != nil {
157+ return err
158+ }
159+
160+ fmt.Println("zot update: downloading archive…")
161+ archivePath := filepath.Join(tmp, assetName)
162+ if err := downloadFile(ctx, assetURL, archivePath); err != nil {
163+ return fmt.Errorf("download archive: %w", err)
164+ }
165+
166+ fmt.Println("zot update: verifying checksum…")
167+ gotSum, err := sha256File(archivePath)
168+ if err != nil {
169+ return fmt.Errorf("hash archive: %w", err)
170+ }
171+ if !strings.EqualFold(gotSum, wantSum) {
172+ return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
173+ }
174+
175+ fmt.Println("zot update: extracting…")
176+ extractDir := filepath.Join(tmp, "extracted")
177+ if err := os.MkdirAll(extractDir, 0o755); err != nil {
178+ return fmt.Errorf("mkdir extract: %w", err)
179+ }
180+ if err := extractArchive(archivePath, archiveFmt, extractDir); err != nil {
181+ return fmt.Errorf("extract archive: %w", err)
182+ }
183+
184+ newBin := filepath.Join(extractDir, "zot")
185+ if runtime.GOOS == "windows" {
186+ newBin = filepath.Join(extractDir, "zot.exe")
187+ }
188+ if st, err := os.Stat(newBin); err != nil || st.IsDir() {
189+ return fmt.Errorf("extracted archive does not contain a zot binary at %s", newBin)
190+ }
191+
192+ curBin, err := os.Executable()
193+ if err != nil {
194+ return fmt.Errorf("resolve current binary path: %w", err)
195+ }
196+ // Resolve symlinks so 'zot' on $PATH that points at /usr/local/bin
197+ // gets actually replaced rather than us writing into the symlink
198+ // target's directory while leaving the link stale.
199+ if resolved, err := filepath.EvalSymlinks(curBin); err == nil {
200+ curBin = resolved
201+ }
202+
203+ fmt.Printf("zot update: replacing %s\n", curBin)
204+ if err := replaceBinary(curBin, newBin); err != nil {
205+ return fmt.Errorf("replace binary: %w", err)
206+ }
207+ fmt.Printf("zot update: installed %s\n", latest)
208+ return nil
209+}
210+
211+// releaseAssetName returns the archive filename for the current
212+// platform and the format (tar.gz / zip) used to extract it. Must
213+// stay in sync with archives.name_template in .goreleaser.yaml:
214+//
215+// {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
216+func releaseAssetName(version string) (name, format string, err error) {
217+ goos := runtime.GOOS
218+ goarch := runtime.GOARCH
219+ switch goos {
220+ case "linux", "darwin":
221+ // supported
222+ case "windows":
223+ // supported
224+ default:
225+ return "", "", fmt.Errorf("unsupported OS for zot update: %s (download manually from the release page)", goos)
226+ }
227+ switch goarch {
228+ case "amd64", "arm64":
229+ // supported
230+ default:
231+ return "", "", fmt.Errorf("unsupported CPU arch for zot update: %s", goarch)
232+ }
233+ if goos == "windows" && goarch == "arm64" {
234+ return "", "", errors.New("windows/arm64 release artifacts are not published; download manually")
235+ }
236+ ext := "tar.gz"
237+ if goos == "windows" {
238+ ext = "zip"
239+ }
240+ return fmt.Sprintf("zot_%s_%s_%s.%s", version, goos, goarch, ext), ext, nil
241+}
242+
243+// downloadFile fetches url to dst, streaming through io.Copy so big
244+// archives don't balloon memory.
245+func downloadFile(ctx context.Context, url, dst string) error {
246+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
247+ if err != nil {
248+ return err
249+ }
250+ if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
251+ req.Header.Set("authorization", "Bearer "+tok)
252+ }
253+ client := &http.Client{Timeout: 90 * time.Second}
254+ resp, err := client.Do(req)
255+ if err != nil {
256+ return err
257+ }
258+ defer resp.Body.Close()
259+ if resp.StatusCode != 200 {
260+ return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
261+ }
262+ f, err := os.Create(dst)
263+ if err != nil {
264+ return err
265+ }
266+ defer f.Close()
267+ if _, err := io.Copy(f, resp.Body); err != nil {
268+ return err
269+ }
270+ return nil
271+}
272+
273+// lookupChecksum parses a GoReleaser checksums.txt file and returns
274+// the sha256 hex for the named asset. Format is:
275+//
276+// <sha256> <filename>
277+//
278+// one entry per line, two spaces between columns.
279+func lookupChecksum(path, asset string) (string, error) {
280+ b, err := os.ReadFile(path)
281+ if err != nil {
282+ return "", fmt.Errorf("read checksums: %w", err)
283+ }
284+ for _, line := range strings.Split(string(b), "\n") {
285+ line = strings.TrimSpace(line)
286+ if line == "" {
287+ continue
288+ }
289+ fields := strings.Fields(line)
290+ if len(fields) < 2 {
291+ continue
292+ }
293+ if fields[1] == asset {
294+ return fields[0], nil
295+ }
296+ }
297+ return "", fmt.Errorf("checksum for %s not listed in checksums.txt", asset)
298+}
299+
300+func sha256File(path string) (string, error) {
301+ f, err := os.Open(path)
302+ if err != nil {
303+ return "", err
304+ }
305+ defer f.Close()
306+ h := sha256.New()
307+ if _, err := io.Copy(h, f); err != nil {
308+ return "", err
309+ }
310+ return hex.EncodeToString(h.Sum(nil)), nil
311+}
312+
313+// extractArchive shells out to the system tar / unzip rather than
314+// pulling in a Go archive lib. Reasoning: zot already depends on
315+// system tar implicitly in a few places, every supported platform
316+// ships tar (BSD tar on macOS handles gzip natively, GNU tar on
317+// Linux, bsdtar on Windows 10+), and the dependency-free release
318+// archives are simple enough that we don't need format-detection
319+// gymnastics.
320+func extractArchive(archive, format, dst string) error {
321+ switch format {
322+ case "tar.gz":
323+ cmd := exec.Command("tar", "-xzf", archive, "-C", dst)
324+ out, err := cmd.CombinedOutput()
325+ if err != nil {
326+ return fmt.Errorf("tar: %v: %s", err, strings.TrimSpace(string(out)))
327+ }
328+ return nil
329+ case "zip":
330+ // PowerShell ships everywhere on supported Windows
331+ // versions and Expand-Archive doesn't need elevation.
332+ ps := fmt.Sprintf("Expand-Archive -LiteralPath %q -DestinationPath %q -Force", archive, dst)
333+ cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
334+ out, err := cmd.CombinedOutput()
335+ if err != nil {
336+ return fmt.Errorf("powershell Expand-Archive: %v: %s", err, strings.TrimSpace(string(out)))
337+ }
338+ return nil
339+ default:
340+ return fmt.Errorf("unknown archive format: %s", format)
341+ }
342+}
343+
344+// replaceBinary writes the new binary in place of the old one,
345+// preserving the old binary's permissions. On Unix we rename
346+// in-place (which works while the binary is running because the
347+// kernel keeps the in-memory inode alive until the process exits).
348+// On Windows the running .exe is locked, so we rename it aside
349+// first, then move the new one in.
350+func replaceBinary(cur, newBin string) error {
351+ info, err := os.Stat(cur)
352+ if err != nil {
353+ return fmt.Errorf("stat current binary: %w", err)
354+ }
355+ mode := info.Mode().Perm()
356+ if mode == 0 {
357+ mode = 0o755
358+ }
359+
360+ if runtime.GOOS == "windows" {
361+ // Rename current aside so we can drop the new one in.
362+ bak := cur + ".old"
363+ // Clean up any stale .old from a previous update.
364+ _ = os.Remove(bak)
365+ if err := os.Rename(cur, bak); err != nil {
366+ return fmt.Errorf("rename current to .old: %w", err)
367+ }
368+ if err := os.Rename(newBin, cur); err != nil {
369+ // Try to put the old one back.
370+ _ = os.Rename(bak, cur)
371+ return fmt.Errorf("install new binary: %w", err)
372+ }
373+ // The .old file is locked until this process exits; leave
374+ // it behind. Next `zot update` cleans it up via the
375+ // os.Remove(bak) above.
376+ return nil
377+ }
378+
379+ // Unix: atomic rename if we're on the same filesystem.
380+ if err := os.Rename(newBin, cur); err == nil {
381+ _ = os.Chmod(cur, mode)
382+ return nil
383+ }
384+ // Cross-fs (temp dir on tmpfs vs binary on a different mount)
385+ // — fall back to copy + chmod + remove.
386+ if err := copyFile(newBin, cur); err != nil {
387+ return fmt.Errorf("copy new binary into place: %w", err)
388+ }
389+ _ = os.Chmod(cur, mode)
390+ return nil
391+}
392+
393+func copyFile(src, dst string) error {
394+ in, err := os.Open(src)
395+ if err != nil {
396+ return err
397+ }
398+ defer in.Close()
399+ tmp := dst + ".new"
400+ out, err := os.Create(tmp)
401+ if err != nil {
402+ return err
403+ }
404+ if _, err := io.Copy(out, in); err != nil {
405+ _ = out.Close()
406+ _ = os.Remove(tmp)
407+ return err
408+ }
409+ if err := out.Close(); err != nil {
410+ _ = os.Remove(tmp)
411+ return err
412+ }
413+ return os.Rename(tmp, dst)
414+}
415+
416+// versionOnly strips the build metadata that main.go appends to the
417+// version string, so "0.0.5 (43da5e5, 2026-05-12)" becomes "0.0.5".
418+func versionOnly(v string) string {
419+ if i := strings.IndexAny(v, " ("); i > 0 {
420+ return strings.TrimSpace(v[:i])
421+ }
422+ return v
423+}
@@ -0,0 +1,423 @@
1+package agent
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "encoding/hex"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "runtime"
15+ "strings"
16+ "time"
17+)
18+
19+// runUpdateCommand dispatches `zot update`. Returns (handled=true, err)
20+// if rawArgs starts with "update"; otherwise (handled=false, nil) so
21+// the main router falls through to the regular flag parser. Mirrors
22+// the shape of runBotCommand / runExtCommand on purpose: identical
23+// dispatch shape keeps the router in cli.go uniform.
24+//
25+// The command:
26+//
27+// 1. Resolves the latest release tag via the GitHub API (same code
28+// path the in-TUI update banner uses, so we never disagree about
29+// "what is latest").
30+// 2. Picks the asset matching the current GOOS/GOARCH using the
31+// name template defined in .goreleaser.yaml.
32+// 3. Downloads checksums.txt and the asset to a temp directory.
33+// 4. Verifies the asset's sha256 against checksums.txt.
34+// 5. Extracts the zot binary from the archive.
35+// 6. Atomically replaces the running binary with the new one.
36+//
37+// Refuses to operate on dev builds (version == "0.0.0") because there
38+// is no meaningful "is newer" comparison and we'd happily downgrade a
39+// freshly-compiled local binary back to whatever's on GitHub.
40+func runUpdateCommand(rawArgs []string, version string) (handled bool, err error) {
41+ if len(rawArgs) == 0 || rawArgs[0] != "update" {
42+ return false, nil
43+ }
44+ // Accept --help/-h for parity with the rest of the CLI.
45+ for _, a := range rawArgs[1:] {
46+ switch a {
47+ case "-h", "--help", "help":
48+ printUpdateHelp()
49+ return true, nil
50+ case "--check":
51+ return true, runUpdateCheck(version)
52+ default:
53+ printUpdateHelp()
54+ return true, fmt.Errorf("unknown update flag: %s", a)
55+ }
56+ }
57+ return true, runUpdate(version)
58+}
59+
60+func printUpdateHelp() {
61+ fmt.Fprintln(os.Stderr, `zot update — replace the current zot binary with the latest release
62+
63+usage:
64+ zot update download and install the newest release
65+ zot update --check show what update is available; install nothing
66+ zot update --help show this help
67+
68+notes:
69+ * The binary must be writable by the current user. On a system-wide
70+ install (e.g. /usr/local/bin/zot owned by root) re-run with sudo.
71+ * Dev builds (version 0.0.0) are refused — they typically come from
72+ 'go install' or a local 'make build' and shouldn't be silently
73+ replaced with a release binary.
74+ * Honours $GITHUB_TOKEN if set, so private-repo releases work.`)
75+}
76+
77+// runUpdateCheck just prints what would happen without doing the
78+// download. Useful as a sanity probe and as something the user can
79+// pipe into scripts.
80+func runUpdateCheck(version string) error {
81+ if version == "" || version == "dev" || version == "0.0.0" {
82+ fmt.Println("zot: dev build (version 0.0.0) — `zot update` is disabled")
83+ return nil
84+ }
85+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
86+ defer cancel()
87+ tag, url, err := fetchLatestRelease(ctx)
88+ if err != nil {
89+ return fmt.Errorf("query latest release: %w", err)
90+ }
91+ latest := strings.TrimPrefix(tag, "v")
92+ current := versionOnly(version)
93+ if !versionLess(current, latest) {
94+ fmt.Printf("zot %s is up to date (latest: %s)\n", current, latest)
95+ return nil
96+ }
97+ fmt.Printf("zot %s -> %s available\n release: %s\n run 'zot update' to install\n", current, latest, url)
98+ return nil
99+}
100+
101+// runUpdate is the meat of `zot update`.
102+func runUpdate(version string) error {
103+ if version == "" || version == "dev" || version == "0.0.0" {
104+ return errors.New("dev build (version 0.0.0): `zot update` is disabled. Build a release tag or download from https://github.com/patriceckhart/zot/releases")
105+ }
106+ current := versionOnly(version)
107+
108+ ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
109+ defer cancel()
110+
111+ fmt.Println("zot update: querying latest release…")
112+ tag, releaseURL, err := fetchLatestRelease(ctx)
113+ if err != nil {
114+ return fmt.Errorf("query latest release: %w", err)
115+ }
116+ latest := strings.TrimPrefix(tag, "v")
117+
118+ if !versionLess(current, latest) {
119+ fmt.Printf("zot %s is already up to date.\n", current)
120+ return nil
121+ }
122+ fmt.Printf("zot update: %s -> %s\n", current, latest)
123+ fmt.Printf("zot update: release page %s\n", releaseURL)
124+
125+ // Pick the archive matching this platform.
126+ assetName, archiveFmt, err := releaseAssetName(latest)
127+ if err != nil {
128+ return err
129+ }
130+ fmt.Printf("zot update: target asset %s\n", assetName)
131+
132+ // We assume the standard GoReleaser layout: assets live under
133+ // https://github.com/<owner>/<repo>/releases/download/<tag>/<file>
134+ base := strings.TrimSuffix(releaseURL, "/")
135+ // releaseURL points at /releases/tag/<tag>; flip it to /releases/download/<tag>
136+ base = strings.Replace(base, "/releases/tag/", "/releases/download/", 1)
137+
138+ assetURL := base + "/" + assetName
139+ sumsURL := base + "/checksums.txt"
140+
141+ tmp, err := os.MkdirTemp("", "zot-update-")
142+ if err != nil {
143+ return fmt.Errorf("create temp dir: %w", err)
144+ }
145+ // Best effort: remove the temp dir at the end. If extraction
146+ // fails midway we leave it behind on purpose for diagnosis;
147+ // users can clear /tmp themselves.
148+ defer func() { _ = os.RemoveAll(tmp) }()
149+
150+ fmt.Println("zot update: downloading checksums.txt…")
151+ sumsPath := filepath.Join(tmp, "checksums.txt")
152+ if err := downloadFile(ctx, sumsURL, sumsPath); err != nil {
153+ return fmt.Errorf("download checksums: %w", err)
154+ }
155+ wantSum, err := lookupChecksum(sumsPath, assetName)
156+ if err != nil {
157+ return err
158+ }
159+
160+ fmt.Println("zot update: downloading archive…")
161+ archivePath := filepath.Join(tmp, assetName)
162+ if err := downloadFile(ctx, assetURL, archivePath); err != nil {
163+ return fmt.Errorf("download archive: %w", err)
164+ }
165+
166+ fmt.Println("zot update: verifying checksum…")
167+ gotSum, err := sha256File(archivePath)
168+ if err != nil {
169+ return fmt.Errorf("hash archive: %w", err)
170+ }
171+ if !strings.EqualFold(gotSum, wantSum) {
172+ return fmt.Errorf("checksum mismatch for %s: got %s, want %s", assetName, gotSum, wantSum)
173+ }
174+
175+ fmt.Println("zot update: extracting…")
176+ extractDir := filepath.Join(tmp, "extracted")
177+ if err := os.MkdirAll(extractDir, 0o755); err != nil {
178+ return fmt.Errorf("mkdir extract: %w", err)
179+ }
180+ if err := extractArchive(archivePath, archiveFmt, extractDir); err != nil {
181+ return fmt.Errorf("extract archive: %w", err)
182+ }
183+
184+ newBin := filepath.Join(extractDir, "zot")
185+ if runtime.GOOS == "windows" {
186+ newBin = filepath.Join(extractDir, "zot.exe")
187+ }
188+ if st, err := os.Stat(newBin); err != nil || st.IsDir() {
189+ return fmt.Errorf("extracted archive does not contain a zot binary at %s", newBin)
190+ }
191+
192+ curBin, err := os.Executable()
193+ if err != nil {
194+ return fmt.Errorf("resolve current binary path: %w", err)
195+ }
196+ // Resolve symlinks so 'zot' on $PATH that points at /usr/local/bin
197+ // gets actually replaced rather than us writing into the symlink
198+ // target's directory while leaving the link stale.
199+ if resolved, err := filepath.EvalSymlinks(curBin); err == nil {
200+ curBin = resolved
201+ }
202+
203+ fmt.Printf("zot update: replacing %s\n", curBin)
204+ if err := replaceBinary(curBin, newBin); err != nil {
205+ return fmt.Errorf("replace binary: %w", err)
206+ }
207+ fmt.Printf("zot update: installed %s\n", latest)
208+ return nil
209+}
210+
211+// releaseAssetName returns the archive filename for the current
212+// platform and the format (tar.gz / zip) used to extract it. Must
213+// stay in sync with archives.name_template in .goreleaser.yaml:
214+//
215+// {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
216+func releaseAssetName(version string) (name, format string, err error) {
217+ goos := runtime.GOOS
218+ goarch := runtime.GOARCH
219+ switch goos {
220+ case "linux", "darwin":
221+ // supported
222+ case "windows":
223+ // supported
224+ default:
225+ return "", "", fmt.Errorf("unsupported OS for zot update: %s (download manually from the release page)", goos)
226+ }
227+ switch goarch {
228+ case "amd64", "arm64":
229+ // supported
230+ default:
231+ return "", "", fmt.Errorf("unsupported CPU arch for zot update: %s", goarch)
232+ }
233+ if goos == "windows" && goarch == "arm64" {
234+ return "", "", errors.New("windows/arm64 release artifacts are not published; download manually")
235+ }
236+ ext := "tar.gz"
237+ if goos == "windows" {
238+ ext = "zip"
239+ }
240+ return fmt.Sprintf("zot_%s_%s_%s.%s", version, goos, goarch, ext), ext, nil
241+}
242+
243+// downloadFile fetches url to dst, streaming through io.Copy so big
244+// archives don't balloon memory.
245+func downloadFile(ctx context.Context, url, dst string) error {
246+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
247+ if err != nil {
248+ return err
249+ }
250+ if tok := os.Getenv("GITHUB_TOKEN"); tok != "" {
251+ req.Header.Set("authorization", "Bearer "+tok)
252+ }
253+ client := &http.Client{Timeout: 90 * time.Second}
254+ resp, err := client.Do(req)
255+ if err != nil {
256+ return err
257+ }
258+ defer resp.Body.Close()
259+ if resp.StatusCode != 200 {
260+ return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
261+ }
262+ f, err := os.Create(dst)
263+ if err != nil {
264+ return err
265+ }
266+ defer f.Close()
267+ if _, err := io.Copy(f, resp.Body); err != nil {
268+ return err
269+ }
270+ return nil
271+}
272+
273+// lookupChecksum parses a GoReleaser checksums.txt file and returns
274+// the sha256 hex for the named asset. Format is:
275+//
276+// <sha256> <filename>
277+//
278+// one entry per line, two spaces between columns.
279+func lookupChecksum(path, asset string) (string, error) {
280+ b, err := os.ReadFile(path)
281+ if err != nil {
282+ return "", fmt.Errorf("read checksums: %w", err)
283+ }
284+ for _, line := range strings.Split(string(b), "\n") {
285+ line = strings.TrimSpace(line)
286+ if line == "" {
287+ continue
288+ }
289+ fields := strings.Fields(line)
290+ if len(fields) < 2 {
291+ continue
292+ }
293+ if fields[1] == asset {
294+ return fields[0], nil
295+ }
296+ }
297+ return "", fmt.Errorf("checksum for %s not listed in checksums.txt", asset)
298+}
299+
300+func sha256File(path string) (string, error) {
301+ f, err := os.Open(path)
302+ if err != nil {
303+ return "", err
304+ }
305+ defer f.Close()
306+ h := sha256.New()
307+ if _, err := io.Copy(h, f); err != nil {
308+ return "", err
309+ }
310+ return hex.EncodeToString(h.Sum(nil)), nil
311+}
312+
313+// extractArchive shells out to the system tar / unzip rather than
314+// pulling in a Go archive lib. Reasoning: zot already depends on
315+// system tar implicitly in a few places, every supported platform
316+// ships tar (BSD tar on macOS handles gzip natively, GNU tar on
317+// Linux, bsdtar on Windows 10+), and the dependency-free release
318+// archives are simple enough that we don't need format-detection
319+// gymnastics.
320+func extractArchive(archive, format, dst string) error {
321+ switch format {
322+ case "tar.gz":
323+ cmd := exec.Command("tar", "-xzf", archive, "-C", dst)
324+ out, err := cmd.CombinedOutput()
325+ if err != nil {
326+ return fmt.Errorf("tar: %v: %s", err, strings.TrimSpace(string(out)))
327+ }
328+ return nil
329+ case "zip":
330+ // PowerShell ships everywhere on supported Windows
331+ // versions and Expand-Archive doesn't need elevation.
332+ ps := fmt.Sprintf("Expand-Archive -LiteralPath %q -DestinationPath %q -Force", archive, dst)
333+ cmd := exec.Command("powershell", "-NoProfile", "-Command", ps)
334+ out, err := cmd.CombinedOutput()
335+ if err != nil {
336+ return fmt.Errorf("powershell Expand-Archive: %v: %s", err, strings.TrimSpace(string(out)))
337+ }
338+ return nil
339+ default:
340+ return fmt.Errorf("unknown archive format: %s", format)
341+ }
342+}
343+
344+// replaceBinary writes the new binary in place of the old one,
345+// preserving the old binary's permissions. On Unix we rename
346+// in-place (which works while the binary is running because the
347+// kernel keeps the in-memory inode alive until the process exits).
348+// On Windows the running .exe is locked, so we rename it aside
349+// first, then move the new one in.
350+func replaceBinary(cur, newBin string) error {
351+ info, err := os.Stat(cur)
352+ if err != nil {
353+ return fmt.Errorf("stat current binary: %w", err)
354+ }
355+ mode := info.Mode().Perm()
356+ if mode == 0 {
357+ mode = 0o755
358+ }
359+
360+ if runtime.GOOS == "windows" {
361+ // Rename current aside so we can drop the new one in.
362+ bak := cur + ".old"
363+ // Clean up any stale .old from a previous update.
364+ _ = os.Remove(bak)
365+ if err := os.Rename(cur, bak); err != nil {
366+ return fmt.Errorf("rename current to .old: %w", err)
367+ }
368+ if err := os.Rename(newBin, cur); err != nil {
369+ // Try to put the old one back.
370+ _ = os.Rename(bak, cur)
371+ return fmt.Errorf("install new binary: %w", err)
372+ }
373+ // The .old file is locked until this process exits; leave
374+ // it behind. Next `zot update` cleans it up via the
375+ // os.Remove(bak) above.
376+ return nil
377+ }
378+
379+ // Unix: atomic rename if we're on the same filesystem.
380+ if err := os.Rename(newBin, cur); err == nil {
381+ _ = os.Chmod(cur, mode)
382+ return nil
383+ }
384+ // Cross-fs (temp dir on tmpfs vs binary on a different mount)
385+ // — fall back to copy + chmod + remove.
386+ if err := copyFile(newBin, cur); err != nil {
387+ return fmt.Errorf("copy new binary into place: %w", err)
388+ }
389+ _ = os.Chmod(cur, mode)
390+ return nil
391+}
392+
393+func copyFile(src, dst string) error {
394+ in, err := os.Open(src)
395+ if err != nil {
396+ return err
397+ }
398+ defer in.Close()
399+ tmp := dst + ".new"
400+ out, err := os.Create(tmp)
401+ if err != nil {
402+ return err
403+ }
404+ if _, err := io.Copy(out, in); err != nil {
405+ _ = out.Close()
406+ _ = os.Remove(tmp)
407+ return err
408+ }
409+ if err := out.Close(); err != nil {
410+ _ = os.Remove(tmp)
411+ return err
412+ }
413+ return os.Rename(tmp, dst)
414+}
415+
416+// versionOnly strips the build metadata that main.go appends to the
417+// version string, so "0.0.5 (43da5e5, 2026-05-12)" becomes "0.0.5".
418+func versionOnly(v string) string {
419+ if i := strings.IndexAny(v, " ("); i > 0 {
420+ return strings.TrimSpace(v[:i])
421+ }
422+ return v
423+}

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