// selfupdate.go — updating oneself from the releases of a Gitea instance. // // The file is meant to be copied: take it into another program, adjust the // configuration block below, hang `--update` and `--check-update` into the // options — done. It needs nothing but the standard library, and apart from // that block it brings no names that do not begin with "selfUpdate" or // "update". // // It assumes the layout build.sh produces: one release per version, whose tag // is the bare number (4.0.64, a leading "v" is allowed), holding one asset // "--" each — that is, exactly the files from ./bin. Under // /api/v1/repos///releases/latest Gitea hands out the newest // release that is neither a draft nor a prerelease; GitHub speaks the same // route with different field names and is therefore not covered. package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "time" ) // ------------------------------------------------------------ Configuration var selfUpdate = selfUpdater{ repo: "https://git.micw.org/mike/mgsh", asset: "mgsh", current: VERSION, // from main.go, set by -ldflags verify: []string{"--version"}, every: 24 * time.Hour, quietEnv: "MGSH_NO_UPDATE_CHECK", } type selfUpdater struct { repo string // repo URL as in the browser: https://host/owner/repo asset string // base name of the assets, "--" is added current string // the running version verify []string // trial run of the download; empty skips it every time.Duration // how often to look on its own; 0 turns that off quietEnv string // this environment variable set: keep quiet as well } // updateRefreshFlag is the option the program calls itself with, in the // background. It is deliberately absent from the help. const updateRefreshFlag = "--update-refresh" // ------------------------------------------------------------ Looking by itself // daily is the hook for the ordinary run of the program. It costs nothing: in // the foreground the network is never touched. What comes back is the line // pointing at a new version — or "", when there is nothing to say; what it // looks like is up to the caller. Should the note be older than `every`, daily // starts a background run on the side, whose answer the next call will find // waiting. func (u selfUpdater) daily() string { if u.every <= 0 || os.Getenv(u.quietEnv) != "" || !updateOnTerminal() { return "" } st := u.loadState() // no file: the zero value, hence due at once if time.Since(st.Checked) >= u.every { // The timestamp moves on before the asking, not after: otherwise two // simultaneous runs start two queries, and a server that is not in the // mood would get a new one on every call. If the note does not stay // put, nothing is asked either — else an unwritable cache directory // would mean one process per call. st.Checked = time.Now() if u.saveState(st) == nil { u.spawnRefresh() } } if st.Latest == "" || updateCompare(st.Latest, u.current) <= 0 { return "" } return fmt.Sprintf("%s %s is available, run '%s --update'", u.asset, st.Latest, u.asset) } // refresh is the background run: ask, write it down, stay quiet. The writing // down is done by latest; if the query fails, the old state remains. func (u selfUpdater) refresh() { _, _ = u.latest() } // spawnRefresh calls this program once more, only to ask, and does not wait. // Without a Wait the child is adopted by init when this process ends — it thus // outlives the call, and the call's output stays untouched by it. func (u selfUpdater) spawnRefresh() { exe, err := os.Executable() if err != nil { return } cmd := exec.Command(exe, updateRefreshFlag) cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil // everything to /dev/null if cmd.Start() == nil { cmd.Process.Release() } } // The hint is meant for the person sitting there. Running in a pipe, in a // script or under cron, the program neither asks nor says anything. func updateOnTerminal() bool { st, err := os.Stderr.Stat() return err == nil && st.Mode()&os.ModeCharDevice != 0 } // --------------------------------------------------------------------- Note // updateState is what is left between two calls: when the last question was // asked and what came of it. type updateState struct { Checked time.Time `json:"checked"` Latest string `json:"latest"` } // The note lives in the cache directory, not in the configuration: if it gets // lost, the only cost is asking once too early. func (u selfUpdater) statePath() (string, error) { dir, err := os.UserCacheDir() if err != nil { return "", err } return filepath.Join(dir, u.asset, "update.json"), nil } func (u selfUpdater) loadState() updateState { var st updateState path, err := u.statePath() if err != nil { return st } b, err := os.ReadFile(path) if err != nil { return st } json.Unmarshal(b, &st) // a broken file counts as none return st } func (u selfUpdater) saveState(st updateState) error { path, err := u.statePath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } b, err := json.Marshal(st) if err != nil { return err } // By way of a file alongside, so that a simultaneous run never comes upon // half a JSON. tmp := path + ".new" if err := os.WriteFile(tmp, b, 0o644); err != nil { return err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return err } return nil } // ------------------------------------------------------------------ The work // check only looks and touches nothing. func (u selfUpdater) check(w io.Writer) error { rel, err := u.latest() if err != nil { return err } if updateCompare(rel.TagName, u.current) <= 0 { fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) return nil } fmt.Fprintf(w, "%s %s is available, running %s\n %s\n run '%s --update' to install it\n", u.asset, rel.TagName, u.current, rel.HTMLURL, u.asset) return nil } // install fetches the newest release and replaces the running file with it. func (u selfUpdater) install(w io.Writer) error { rel, err := u.latest() if err != nil { return err } if updateCompare(rel.TagName, u.current) <= 0 { fmt.Fprintf(w, "%s %s is up to date\n", u.asset, u.current) return nil } want := fmt.Sprintf("%s-%s-%s", u.asset, runtime.GOOS, runtime.GOARCH) var src *updateAsset for i := range rel.Assets { if rel.Assets[i].Name == want { src = &rel.Assets[i] break } } if src == nil { names := make([]string, len(rel.Assets)) for i, a := range rel.Assets { names[i] = a.Name } return fmt.Errorf("release %s has no %q (only %s)", rel.TagName, want, strings.Join(names, ", ")) } exe, err := os.Executable() if err != nil { return fmt.Errorf("cannot locate the running binary: %w", err) } // An installed mgsh is often a symlink into ./bin. What should be replaced // is the file behind it, not the link. if real, err := filepath.EvalSymlinks(exe); err == nil { exe = real } mode := os.FileMode(0o755) if st, err := os.Stat(exe); err == nil { mode = st.Mode().Perm() } fmt.Fprintf(w, "downloading %s %s (%s)\n", want, rel.TagName, updateSize(src.Size)) tmp, err := u.download(src, exe, mode) if err != nil { return err } defer os.Remove(tmp) // only bites when the renaming below falls through if err := u.probe(tmp, rel.TagName); err != nil { return err } if err := updateReplace(tmp, exe); err != nil { return err } fmt.Fprintf(w, "%s %s → %s, at %s\n", u.asset, u.current, rel.TagName, exe) return nil } func (u selfUpdater) download(a *updateAsset, exe string, mode os.FileMode) (string, error) { // The new file comes into being next to the old one: same filesystem, so // the renaming at the end is one atomic step and not half a copy. It also // comes into being before the first byte — a missing write permission ought // to show up before a few megabytes have gone down the wire. dir := filepath.Dir(exe) f, err := os.CreateTemp(dir, "."+filepath.Base(exe)+".new") if err != nil { var pe *os.PathError // the path is in the message already if errors.As(err, &pe) { err = pe.Err } return "", fmt.Errorf("cannot write to %s: %w", dir, err) } tmp := f.Name() resp, err := updateGet(context.Background(), a.URL) if err != nil { f.Close() os.Remove(tmp) return "", err } defer resp.Body.Close() n, err := io.Copy(f, resp.Body) if cerr := f.Close(); err == nil { err = cerr } if err == nil && a.Size > 0 && n != a.Size { err = fmt.Errorf("got %d of %d bytes from %s", n, a.Size, a.URL) } if err == nil { err = os.Chmod(tmp, mode) } if err != nil { os.Remove(tmp) return "", err } return tmp, nil } // probe calls the freshly fetched runner once. That catches a file that is // truncated, built for the wrong platform, or not executable in the first // place, before it replaces the running one. func (u selfUpdater) probe(path, tag string) error { if len(u.verify) == 0 { return nil } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() out, err := exec.CommandContext(ctx, path, u.verify...).CombinedOutput() if err != nil { return fmt.Errorf("the downloaded binary does not run: %w", err) } if !strings.Contains(string(out), strings.TrimPrefix(tag, "v")) { return fmt.Errorf("the downloaded binary reports %q, expected %s", strings.TrimSpace(string(out)), tag) } return nil } // updateReplace swaps the running file for the new one. func updateReplace(tmp, exe string) error { if err := os.Rename(tmp, exe); err == nil { return nil } // Unix overwrites the file of a running program without complaint, Windows // does not: there the old one has to be got out of the way first. Deleting // it becomes possible when this process ends at the earliest — so the // tidying up is allowed to fail. old := exe + ".old" os.Remove(old) if err := os.Rename(exe, old); err != nil { return fmt.Errorf("cannot replace %s: %w", exe, err) } if err := os.Rename(tmp, exe); err != nil { os.Rename(old, exe) // back to how it was return fmt.Errorf("cannot replace %s: %w", exe, err) } os.Remove(old) return nil } // -------------------------------------------------------------------- Gitea type updateRelease struct { TagName string `json:"tag_name"` HTMLURL string `json:"html_url"` Assets []updateAsset `json:"assets"` } type updateAsset struct { Name string `json:"name"` Size int64 `json:"size"` URL string `json:"browser_download_url"` } func (u selfUpdater) latest() (updateRelease, error) { base, err := u.apiBase() if err != nil { return updateRelease{}, err } // The question is a small one; if it hangs, it does not hang for long. The // generous time limit of updateClient is meant for the download. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() resp, err := updateGet(ctx, base+"/releases/latest") if err != nil { return updateRelease{}, err } defer resp.Body.Close() var rel updateRelease if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { return updateRelease{}, fmt.Errorf("unexpected answer from %s: %w", base, err) } if rel.TagName == "" { return updateRelease{}, fmt.Errorf("%s has no releases", u.repo) } // Every question that succeeds fills the note — no matter whether it came // from --update, from --check-update or from the background run. u.saveState(updateState{Checked: time.Now(), Latest: rel.TagName}) return rel, nil } // apiBase turns https://host/owner/repo into the API root of the repo. func (u selfUpdater) apiBase() (string, error) { bad := fmt.Errorf("repo %q: expected https://host/owner/repo", u.repo) ref, err := url.Parse(strings.TrimSuffix(strings.TrimSuffix(u.repo, "/"), ".git")) if err != nil || ref.Host == "" { return "", bad } parts := strings.Split(strings.Trim(ref.Path, "/"), "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", bad } return fmt.Sprintf("%s://%s/api/v1/repos/%s/%s", ref.Scheme, ref.Host, parts[0], parts[1]), nil } // One time limit for all of it: the look costs a few hundred milliseconds, the // download a few megabytes — both may hang, but not forever. var updateClient = &http.Client{Timeout: 5 * time.Minute} func updateGet(ctx context.Context, target string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "selfupdate.go (+"+runtime.GOOS+"/"+runtime.GOARCH+")") resp, err := updateClient.Do(req) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("GET %s: %s", target, resp.Status) } return resp, nil } // ------------------------------------------------------------------ Numbers // updateCompare compares two versions component by component, numerically, so // that 2.1.10 lands behind 2.1.9 and not in front of it. A leading "v" does not // count, missing places count as 0 (2.1 == 2.1.0), and a suffix on the number // makes the version older, not newer (2.1.6-rc1 < 2.1.6). The result is the one // of strings.Compare: -1, 0, 1. func updateCompare(a, b string) int { as := strings.Split(strings.TrimPrefix(a, "v"), ".") bs := strings.Split(strings.TrimPrefix(b, "v"), ".") for i := 0; i < len(as) || i < len(bs); i++ { x, y := "0", "0" if i < len(as) { x = as[i] } if i < len(bs) { y = bs[i] } if c := updateComparePart(x, y); c != 0 { return c } } return 0 } func updateComparePart(a, b string) int { na, ra := updateSplitNum(a) nb, rb := updateSplitNum(b) switch { case na != nb: if na < nb { return -1 } return 1 case ra == rb: return 0 case ra == "": // 2.1.6 is finished, 2.1.6-rc1 is not yet return 1 case rb == "": return -1 } return strings.Compare(ra, rb) } // updateSplitNum separates "10-rc1" into 10 and "-rc1". func updateSplitNum(s string) (int, string) { i := 0 for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ } n, _ := strconv.Atoi(s[:i]) return n, s[i:] } // updateSize is deliberately a small formatting of its own and not humanSize // from colors.go — the file is meant to stand on its own. func updateSize(b int64) string { const k = 1024 switch { case b > k*k: return fmt.Sprintf("%.1f MB", float64(b)/k/k) case b > k: return fmt.Sprintf("%.1f KB", float64(b)/k) default: return fmt.Sprintf("%d B", b) } }