Skip to content

Latest commit

Β 

History

46 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸ”„Β Β go-selfupdate

Self-update and version notifications for Go CLIs β€” checksum-verified, atomically installed, wired in one call


Release Go Version License


CI / CD Β Β  Build Last Commit Β Β Β Β  Quality Β Β  Coverage
Security Β Β  Scorecard Security Β Β Β Β  Docs Β Β  Go Reference Bitcoin


Project Navigation

πŸ“¦Β Installation ⚑ QuickΒ Start πŸ§ͺΒ ExamplesΒ &Β Tests
πŸ“šΒ Documentation πŸ› οΈΒ CodeΒ Standards 🧭 DesignΒ Notes
πŸ€–Β AIΒ Usage βš–οΈΒ License πŸ‘₯Β Maintainers

🧩 About

Every CLI eventually grows a self-updater β€” and a lot of them grow it badly: no checksum, a half-written binary after a flaky download, a ../../etc surprise hiding in a tarball. go-selfupdate is that feature written once, carefully, so you never have to write it again: resolve the latest GitHub release, download the right asset, verify its SHA-256, extract it safely, and atomically swap the running binary β€” plus the passive "a new version is available" banner, and an optional supervised upgrade for tools that run as a service.

Adopting it is a diff that deletes code. A tool with its own updater carries two to five hundred lines of release lookup, archive extraction, and binary replacement. The replacement is one function call.

  • One-call adoption β€” cobracmd.Attach registers update (and upgrade, its alias) with --check, --force, and --verbose, and wires the passive banner, all from a single config. Drop to New + AttachBanner when you want the command and the notice configured separately.
  • Checksum-verified, always β€” every archive is hashed as it streams and matched against the release's checksums.txt. A mismatch aborts before a single byte reaches your binary; a release with no checksums file is refused outright, never installed unverified.
  • Refuses before it touches the network β€” an unsupported platform, a binary another installer owns (Homebrew, go install), and a non-writable install directory are each caught before anything is downloaded, so the answer is instant and the message says what to do.
  • Guarded extraction β€” path traversal (../) is rejected, exotic file modes are normalized away (no setuid surprises), and a size cap defuses a decompression bomb.
  • Atomic replacement β€” stage a sibling <target>.new, fsync it, chmod it, then rename it over the target in the same directory. The rename is atomic and a running process keeps working: the binary on disk is always either the old one or the new one, never a half-written nothing.
  • Errors you can act on β€” every failure maps to exactly one errors.Is-matchable sentinel, wrapped with the concrete path or asset, so the message alone tells a user their next move.
  • Passive, never pushy β€” the "new version" notice is opt-out, TTL-cached, CI-silent, and swallows every error (including panics). An update check can never be the reason a command fails.
  • Supervised upgrades β€” the managed/ sub-package defers inside a drain window, runs a post-upgrade health check, and reports a rollback outcome when it fails β€” for tools that run as a long-lived service.
  • Almost no dependencies β€” the core update path imports only the standard library; cobra enters solely through the optional cobracmd/ sub-package. Pure Go, nothing exotic.

Why it matters: an unverified self-updater is a remote-code-execution primitive wearing a convenience feature's clothes. go-selfupdate refuses to write anything it has not hashed against a published checksum, and refuses to guess when it can't β€” so the one code path that replaces your users' binary is the one path you never have to re-audit per project.

Platforms: macOS and Linux get the full self-update today. On Windows, Check, --check, and the update banner already work; Install is coming soon β€” until then it returns a clear message pointing at the releases page instead of failing halfway.


πŸ“¦ Installation

go-selfupdate requires a supported release of Go.

go get -u github.com/mrz1836/go-selfupdate

Get the MAGE-X build tool for development:

go install github.com/mrz1836/mage-x/cmd/magex@latest

⚑ Quick Start

1. Wire it into a cobra CLI

One call adds the whole feature β€” the active update command and the passive banner β€” from a single config:

import (
    selfupdate "github.com/mrz1836/go-selfupdate"
    "github.com/mrz1836/go-selfupdate/cobracmd"
)

// Registers `widget update` (and `widget upgrade`, its alias) with
// --check, --force, and --verbose, and wires the "new version available"
// banner. State the tool's identity once.
cobracmd.Attach(root, selfupdate.Config{
    Owner:          "acme",
    Repo:           "widget",
    BinaryName:     "widget",
    CurrentVersion: version, // usually stamped via -ldflags "-X main.version=..."
})

Want the command and the banner configured separately β€” a custom cache directory, a different banner stream? Use the two pieces Attach is built from:

root.AddCommand(cobracmd.New(selfupdate.Config{ /* … */ }))
cobracmd.AttachBanner(root, notify.Config{ /* … */ })

A complete, buildable program is in examples/minimal.


2. Or drive it programmatically

Prefer to own the control flow? The core API is two functions β€” Check never writes, Install runs the full pipeline:

info, err := selfupdate.Check(ctx, cfg)                       // never writes
result, err := selfupdate.Install(ctx, cfg, selfupdate.WithForce())

Check is safe to call from anywhere β€” a doctor command, a status line, a test. It is the same call --check makes.

InstallPreflight(cfg) answers the location half of the question with no network at all: it resolves the running binary and applies the same managed-install and writable-directory guards Install will, returning ErrManagedInstall or ErrInstallDirNotWritable when a future update would be blocked by where the binary lives. --check uses it to warn β€” while still reporting the version β€” when the install directory is not writable, so a binary in a root-owned location like /usr/local/bin is flagged before a release ships rather than only when one does. The guidance points at a user-writable directory (~/.local/bin), never sudo: elevating just re-creates the binary root-owned and moves the wall to the next release.


3. What happens during an update

The order matters, and it is part of the contract:

  1. Platform guard β€” an unsupported GOOS/GOARCH is refused before an HTTP client is even constructed, so a user on a platform you do not publish for pays no network round-trip and gets a straight answer.
  2. Managed-install detection β€” a binary another installer owns (a package manager's cellar, a toolchain bin directory) is refused with the command that does own it, rather than silently overwritten.
  3. Writable-directory probe β€” install dir not writable: <path> arrives before the download, not after it.
  4. Release resolution β€” the gh CLI first when it is present and authenticated, falling back to the GitHub REST API.
  5. Checksum-verified download β€” the archive is hashed as it streams and compared against the release's checksums.txt. A mismatch aborts before anything is written to the install path. A release with no checksums file is refused outright.
  6. Guarded extraction β€” path traversal (../) is rejected, file modes are normalized, and a size cap stops a decompression bomb.
  7. Atomic replace β€” stage a sibling <target>.new, fsync, chmod, then rename it over the target. The rename is same-directory, so it is atomic and never leaves the command missing (and the running process keeps reading the old inode).

Every stage returns its own sentinel error (ErrUnsupportedPlatform, ErrManagedInstall, ErrInstallDirNotWritable, ErrAssetNotFound, ErrChecksumMismatch, …) wrapped with the concrete path or asset that failed, so errors.Is works and the message alone tells a user what to do next.

Windows: the write path (step 7) needs a rename-aside dance rather than the POSIX atomic rename, so Install is gated on Windows for now β€” it returns ErrWindowsNotSupported with a link to the releases page instead of failing halfway through. Check, --check, and the passive banner work on Windows today; full self-update is coming soon.


Check vs. Install

Check answers a question; Install acts on it. Check writes nothing, ever β€” it is the same call the --check flag makes.

Check Install
Network release metadata only metadata + archive + checksums
Writes none, ever the target binary, atomically
Backing flag --check the bare command

An absent platform asset is reported as an error, but Check still returns a populated Info alongside it β€” so --check output can show which version exists even when this platform has nothing to download.

Configuration reference

selfupdate.Config requires only Owner, Repo, and BinaryName; everything else has a production default applied on normalization (your Config is never mutated):

Field Default Notes
CurrentVersion dev A development build is never replaced without --force.
TargetPath os.Executable() with symlinks resolved Replaces the real file, not a link to it.
Client 5-minute timeout Any *http.Client.
TokenEnvVar none Consulted before GITHUB_TOKEN and GH_TOKEN.
Source gh CLI, then REST Any ReleaseSource implementation; tests inject a stub.
Platforms linux/darwin/windows Γ— amd64/arm64 Narrow it when you publish fewer.
Stdout os.Stdout Progress and the version-transition line; the command wires this to cobra's stream.
Logger slog.Default()

The passive banner's own stream, cache, and style live on notify.Config, not here β€” the two packages keep their configuration separate.

Per-call switches are Option values: WithForce() installs even when not newer, WithVerbose() narrates each step, and WithCheckOnly() reports what an install would do without writing anything.

Environment variables

Names are derived from the application, so two tools built on this library never fight over one another's settings. <APP> is the uppercased application name.

Variable Effect
<APP>_GITHUB_TOKEN, then GITHUB_TOKEN, then GH_TOKEN Authenticates release lookups; raises the rate limit and reaches private repositories.
<APP>_NO_UPDATE_CHECK Silences the passive notice for this tool.
NO_UPDATE_CHECK Silences it for every tool built on this library.
<APP>_UPDATE_CHECK_INTERVAL Overrides the cache TTL, clamped to [1h, 720h].
CI Any truthy value disables the passive notice entirely.
NO_COLOR Renders the banner without color.

The notice is passive-only. Nothing here ever downloads or installs on its own; an update happens when a user asks for one.

Sub-packages

notify/ β€” the passive notice. A TTL-cached check plus the banner that reports it. It is built to be ignorable: StartBackgroundCheck swallows every error, including panics, so an update check can never be the reason a CLI fails. The cache is written atomically and lives under os.UserConfigDir()/<app> by default β€” pass CacheDir to keep a location your tool already uses.

result := notify.Check(ctx, cfg)   // cached
notify.ShowBanner(cfg, result)     // silent unless an update exists

managed/ β€” supervised upgrades. For a tool that runs as a long-lived service, where "replace the binary now" is the wrong answer. RunManaged defers inside a caller-supplied drain window, runs a post-upgrade health check, and reports a rollback outcome when that check fails. A deferral is not an error: it returns OutcomeDeferred with a nil error when the clock is inside the window, so a supervisor can treat it as success and retry later.

window, err := managed.NewDrainWindow("22:00", "02:00") // wraps midnight

outcome, err := managed.RunManaged(ctx, managed.ManagedConfig{
    Window:      window,
    Upgrade:     func(context.Context) error { /* … */ },
    HealthCheck: func(context.Context) error { /* … */ },
    Rollback:    func(context.Context) error { /* … */ },
})

The core package does not import managed/; the dependency runs one way only.

cobracmd/ β€” the drop-in command. Attach does both halves in one call; New builds just the command and AttachBanner wires just the notice when you need them apart. All three are shown in step 1 above.

Release conventions

The library expects the layout GoReleaser produces by default:

  • Archives named <project>_<version>_<os>_<arch>.tar.gz, for every OS including Windows.
  • A SHA-256 checksums asset, conventionally <project>_<version>_checksums.txt, in the standard <hex> <filename> format.
  • Plain x.y.z releases published as latest.

The checksum filename is matched leniently β€” any asset ending in checksums.txt will do, since a project that leaves the checksum block at its default still produces one. The checksum value is not lenient: missing, unparseable, or absent-for-this-asset is a hard failure, never a warning.


πŸ“š Documentation


Repository Features

This repository includes 25+ built-in features covering CI/CD, security, code quality, developer experience, and community tooling.

View the full Repository Features list β†’

Library Deployment

This project uses goreleaser for streamlined binary and library deployment to GitHub. To get started, install it via:

brew install goreleaser

The release process is defined in the .goreleaser.yml configuration file.

Then create and push a new Git tag using:

magex version:bump push=true bump=patch branch=master

This process ensures consistent, repeatable releases with properly versioned artifacts and metadata.

Pre-commit Hooks

Set up the Go-Pre-commit System to run the same formatting, linting, and tests defined in AGENTS.md before every commit:

go install github.com/mrz1836/go-pre-commit/cmd/go-pre-commit@latest
go-pre-commit install

The system is configured via modular env files in .github/env/ and provides 17x faster execution than traditional Python-based pre-commit hooks. See the complete documentation for details.

GitHub Workflows

All workflows are driven by modular configuration in .github/env/ β€” no YAML editing required.

View all workflows and the control center β†’

Updating Dependencies

To update all dependencies (Go modules, linters, and related tools), run:

magex deps:update

This command ensures all dependencies are brought up to date in a single step, including Go modules and any tools managed by MAGE-X. It is the recommended way to keep your development environment and CI in sync with the latest versions.

Build Commands

View all build commands

magex help

πŸ§ͺ Examples & Tests

All unit tests run via GitHub Actions and use Go version 1.25.x. View the configuration file.

The examples/minimal directory contains a runnable CLI β€” an update command with the standard flags plus the passive banner β€” wired end to end:

go build ./examples/minimal
./minimal version
./minimal update --check

The example points at a repository that does not exist, so update reports that it cannot resolve a release. That is the intended outcome β€” the value is the wiring, not the download. Point Owner and Repo at your own project and it works.

The suite is written against the standard library testing package β€” table-driven unit tests plus fuzz tests over the parsing and extraction paths, with a stub ReleaseSource so no test touches the network.

Run all tests (fast):

magex test

Run all tests with race detector (slower):

magex test:race

🧭 Design Notes

A few decisions shape how this library behaves. The ones worth knowing about:

Release-binary install only β€” there is no toolchain fallback. This is the one decision that changes behavior for a tool adopting the library, so it is stated plainly: a go install …@vX route is not offered, and no flag re-enables it. The reason is mechanical rather than stylistic β€” a single replace directive in a consuming module makes a versioned module query impossible, so for exactly the projects that would need a fallback, the fallback cannot fire. It was dead code posing as a safety net. A guard test fails the build if the route ever reappears.

The consequence is that the single remaining route has to be legible when it fails, which is why the three pre-network gates above are mandatory and why every stage has its own error. It also means the first install is a release-asset download β€” fetch the archive for your platform from the releases page, verify it against checksums.txt, and put the binary on your PATH. After that, the tool updates itself.

Atomic .new + rename, not a .backup dance. Some implementations move the current binary aside and then write the new one, which leaves a window where the command does not exist at all. Writing a sibling file and renaming it over the target closes that window: the binary is either the old one or the new one, never missing.

Conservative version comparison. A version that does not parse is treated as not newer β€” the failure mode of the alternative is a tool that reinstalls itself forever. A purely numeric string (a build number, CalVer like 20240101) is a version, not a commit hash, so it is never mistaken for a development marker. And when two versions share a numeric core, a prerelease sorts below its final release, so someone on v1.2.0-rc2 is correctly offered v1.2.0.

A development build is never replaced without --force. An unstamped build (dev, an empty version, a bare commit hash) has no version to compare and is usually the machine of the person writing the code. update reports the release that exists and stops; --force installs it deliberately.

Windows self-update is coming soon. Replacing a running .exe on Windows needs a rename-aside dance rather than the POSIX atomic rename, so Install is gated there for now. Check and the passive banner already work on Windows, and gating the write path keeps a Windows user from a half-finished download that fails at the last step β€” they get a clear pointer to the releases page instead.

Latest only β€” no release channels. No stable/beta/edge selection, because carrying channel machinery for projects that only ever publish x.y.z is complexity with no caller. Adding it later is additive and non-breaking.

tar.gz only. No .zip handling, for the same reason: nothing in scope produces one.

Every seam is a Config field, not a package-level variable. A library is imported by whoever wants it, so process-global mutable state would let one consumer's tests reach into another's cache.


πŸ› οΈ Code Standards

Read more about this Go project's code standards.


πŸ€– AI Usage & Assistant Guidelines

Read the AI Usage & Assistant Guidelines for details on how AI is used in this project and how to interact with the AI assistants.


πŸ‘₯ Maintainers

MrZ
MrZ

🀝 Contributing

View the contributing guidelines and please follow the code of conduct.

How can I help?

All kinds of contributions are welcome πŸ™Œ! The most basic way to show your support is to star 🌟 the project, or to raise issues πŸ’¬. You can also support this project by becoming a sponsor on GitHub πŸ‘ or by making a bitcoin donation to ensure this journey continues indefinitely! πŸš€

Stars


πŸ“ License

License

About

πŸ”„ Self-update and version notifications for Go CLIs

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages