// Self-update (#161): the binary knows its own version, the apt static // site publishes the latest, and carlosframework/releases holds the // artifacts — so a stale workstation install can notice and fix itself. // Everything here is workstation-only by construction: boxes (bucket env, // root) are rolled by update-platform-binary.sh and adoption, and a // self-updating box binary would fight that machinery. package main import ( "context" "encoding/json" "errors" "flag" "fmt" "log" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "time" "github.com/carlosframework/platform/internal/publicrelease" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/version" ) // Vars, not consts: the tests point them at httptest servers and a test // keypair. Production never changes them. var ( updateLatestURL = "https://apt.carlosframework.com/latest.txt" // Tag-pinned, never "latest/download": the floating alias can serve an // older authentic release than the one latest.txt promised, and every // artifact of a self-replace must come from the ONE tag being claimed // (review 2026-08-12: an authentic-but-old sums+binary pair must not // be acceptable, or a caching layer can pin workstations to a // known-vulnerable release without a signature error). updateDownloadBase = "https://github.com/carlosframework/releases/releases/download" // updateGOOS is runtime.GOOS behind a var for the same reason the URLs // above are: the Windows branch is otherwise unreachable from a test on // any machine we build on. Production never changes it. updateGOOS = runtime.GOOS // cliUpdatePubKey verifies SHA256SUMS.txt.sig on self-replace: the // public half of the dedicated CLI-update keypair (private half lives in the deployment's // secret store — NOT the release key; different blast // radius, different rotation story). cliUpdatePubKey = "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=" ) // releaseTag matches the version stamp of a published build ("v0.7.0"). // Anything else — "dev" above all — never checks and never updates: there // is no meaningful "newer than dev". var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) func releaseBuild(build string) bool { return releaseTag.MatchString(build) } // newerVersion reports whether latest (a release tag) is strictly newer // than current (also a release tag). Unparseable input is never "newer" — // a garbled feed must not nag anyone. func newerVersion(latest, current string) bool { l, lok := release.ParseSemVer(strings.TrimPrefix(latest, "v")) c, cok := release.ParseSemVer(strings.TrimPrefix(current, "v")) return lok && cok && l.Compare(c) > 0 } // noHintVerbs are the box/daemon/plumbing verbs the update hint stays out // of — either they run where updates are the platform's job, or their // output is parsed (version: the tap formula's test asserts on it). var noHintVerbs = map[string]bool{ "edge": true, "agent": true, "adopt": true, "bootstrap": true, "add": true, "remove": true, "route": true, "ops": true, "status": true, "vet": true, "release-keygen": true, "version": true, "help": true, "-h": true, "--help": true, "update": true, "system": true, } type updateCache struct { CheckedAt time.Time `json:"checked_at"` Latest string `json:"latest"` } func updateCachePath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".carlos", "update-check.json"), nil } // updateFeed builds the publicrelease.Feed from this package's vars, at // call time — never cached — so update_test.go's repointing of the vars // (a fresh httptest server per test) always reaches the feed it expects. func updateFeed() publicrelease.Feed { return publicrelease.Feed{LatestURL: updateLatestURL, DownloadBase: updateDownloadBase, PubKey: cliUpdatePubKey} } // fetchLatest asks the apt site which release is current. One small GET // with its own short deadline — the caller is done with its real work, and // a slow feed must cost nobody anything. func fetchLatest(timeout time.Duration) (string, error) { f := updateFeed() f.Client = &http.Client{Timeout: timeout} return f.Latest(context.Background()) } // maybeUpdateHint prints the one-line "a newer carlos exists" nudge after // a successful member verb — at most once per 24h (the network check and // the hint share the cache window), and only where a hint can possibly be // wanted: a human at a terminal, running a release build, off-box, not // opted out. Every failure is silence; this must never make a working // command look broken. func maybeUpdateHint(cmd string) { if noHintVerbs[cmd] || !interactiveTerminal() || !releaseBuild(version.Build) { return } if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { return } if os.Geteuid() == 0 || os.Getenv("CARLOS_NO_UPDATE_CHECK") != "" { return } if off, err := configUpdateCheckOff(); err != nil || off { return } path, err := updateCachePath() if err != nil { return } var cache updateCache if raw, err := os.ReadFile(path); err == nil { // A future checked_at (clock skew, corrupt write) must re-check, // not silence the hint until the clock catches up. if json.Unmarshal(raw, &cache) == nil { if since := time.Since(cache.CheckedAt); since >= 0 && since < 24*time.Hour { return } } } // The attempt is stamped BEFORE the fetch: a blackholed egress must // cost one command its 1.5s, not every command forever. Latest is // informational (for a human reading the file) — the hint only ever // speaks at check time, so a stale value never re-nags. writeCache := func(latest string) { if raw, err := json.Marshal(updateCache{CheckedAt: time.Now().UTC(), Latest: latest}); err == nil { if err := os.MkdirAll(filepath.Dir(path), 0o700); err == nil { os.WriteFile(path, raw, 0o600) } } } writeCache("") latest, err := fetchLatest(1500 * time.Millisecond) if err != nil { return } writeCache(latest) if newerVersion(latest, version.Build) { mutedf(stdout, "carlos %s is available (you have %s) — run: carlos update", latest, version.Build) } } // configUpdateCheckOff walks the config files like the other defaults do: // nearest .carlos/config with an update_check key, else the global. Only // the literal "off" disables. func configUpdateCheckOff() (bool, error) { cfg, src, err := walkConfig(func(c cliConfig) bool { return c.UpdateCheck != "" }) if err != nil || src == "" { return false, err } return cfg.UpdateCheck == "off", nil } // installMethod classifies how the running binary got where it is, so // `carlos update` hands package-managed installs to their manager instead // of fighting it. func installMethod(exe string) string { // Windows first, before any path matching: there is no apt and no brew // in the Windows story, and no self-replace either (os.Rename cannot // overwrite a running .exe), so the zip that carried this binary here // is also how it leaves. A Windows path that happens to contain // /Cellar/ is a coincidence, not a formula. if updateGOOS == "windows" { return "zip" } // Both brews keep formula binaries under a Cellar (/opt/homebrew/Cellar, // ~/.linuxbrew/Cellar) — matching looser substrings ("/homebrew/") would // also match unrelated paths that merely contain the word. if strings.Contains(exe, "/Cellar/") { return "brew" } if dpkg, err := exec.LookPath("dpkg"); err == nil { out, err := exec.Command(dpkg, "-S", exe).Output() if err == nil && strings.HasPrefix(string(out), "carlos:") { return "apt" } } return "binary" } func runUpdate(args []string) { fs := flag.NewFlagSet("update", flag.ExitOnError) yes := fs.Bool("y", false, "replace without asking (plain-binary installs only)") fs.Usage = flagUsage(fs, "") fs.Parse(args) if os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != "" || os.Getenv("CARLOS_DEPLOYMENT_DIR") != "" { log.Fatal("carlos update is for workstation installs — a box's binary is rolled by update-platform-binary.sh and adoption") } if os.Geteuid() == 0 { log.Fatal("carlos update refuses to run as root — box binaries are rolled by the platform, not self-updated") } if !releaseBuild(version.Build) { log.Fatalf("this is a %q build, not a published release — update by rebuilding from source", version.Build) } latest, err := fetchLatest(10 * time.Second) if err != nil { log.Fatalf("could not read the release feed: %v", err) } if !newerVersion(latest, version.Build) { fmt.Fprintf(stdout, "carlos %s is the latest release — nothing to do\n", version.Build) return } exe, err := os.Executable() if err == nil { exe, err = filepath.EvalSymlinks(exe) } if err != nil { log.Fatalf("cannot locate the running binary: %v", err) } switch installMethod(exe) { case "brew": fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) fmt.Fprintln(stdout, "this install is Homebrew's — update with: brew upgrade carlosframework/tap/carlos") return case "apt": fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) fmt.Fprintln(stdout, "this install is apt's — update with: sudo apt update && sudo apt install carlos") return case "zip": // The one install method whose update is manual. Windows cannot // rename a new binary over the running .exe, so rather than ship a // self-replace nobody on this project can test, the command tells // the truth and hands over the exact URL — the staleness check // above has already done the part that needed the network. fmt.Fprintf(stdout, "carlos %s is available (you have %s)\n", latest, version.Build) fmt.Fprintf(stdout, "Windows installs update by hand — download and unzip over %s:\n\n %s\n\n", exe, updateDownloadBase+"/"+latest+"/"+windowsAsset()) return } fmt.Fprintf(stdout, "carlos %s is available (you have %s); this binary: %s\n", latest, version.Build, exe) // Non-TTY never self-replaces, -y or not (spec): provisioning belongs // to package managers and image builds, not to a CLI quietly swapping // itself in a pipeline. -y only skips the question for a human. if !interactiveTerminal() { log.Fatal("carlos update replaces a binary only at a terminal — scripts should install the release directly") } if !*yes && !promptYes(stdout, fmt.Sprintf("download %s and replace it? [y/N] ", latest)) { return } if err := selfReplace(exe, latest); err != nil { if errors.Is(err, os.ErrPermission) { log.Fatalf("no permission to replace %s — re-run as: sudo carlos update", exe) } log.Fatalf("update: %v", err) } fmt.Fprintf(stdout, "%s carlos %s -> %s (%s)\n", colorFor(stdout).ok("updated:"), version.Build, latest, exe) } // windowsAsset names the zip published for this machine's architecture. // It shares the "carlos--" stem with the raw binaries in // SHA256SUMS.txt below, so the checksum list has one naming rule, not two. func windowsAsset() string { return fmt.Sprintf("carlos-%s-%s.zip", updateGOOS, runtime.GOARCH) } // selfReplace downloads the matching artifact for this OS/arch — from the // TAG-pINNED release path, so the bytes installed are the version claimed — // proves it against the SIGNED checksum list, runs the replacement's own // `version` as a final lie detector, and renames it over exe: temp file // beside the target then rename, the same crash-safety rule as the config // writes, so no failure can leave half a binary answering `carlos`. func selfReplace(exe, latest string) error { asset := fmt.Sprintf("carlos-%s-%s", runtime.GOOS, runtime.GOARCH) binData, _, err := updateFeed().Asset(context.Background(), latest, asset, 256<<20) if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(exe), ".carlos-update-*") if err != nil { return err } defer os.Remove(tmp.Name()) if err := tmp.Chmod(0o755); err != nil { tmp.Close() return err } if _, err := tmp.Write(binData); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } // The lie detector: before anything is replaced, the candidate itself // must report the version this whole exchange promised. Catches a // re-pointed tag, a mislabeled artifact, and "updated!" prints that // would otherwise claim a version that never arrived. if out, err := exec.Command(tmp.Name(), "version").Output(); err != nil || !strings.Contains(string(out), "carlos "+latest) { return fmt.Errorf("the downloaded binary reports %q, not %s — refusing to install it", strings.TrimSpace(string(out)), latest) } return os.Rename(tmp.Name(), exe) }