Self-update and version notifications for Go CLIs β checksum-verified, atomically installed, wired in one call
CI / CD Β Β
|
|
Β Β Β Β Quality Β Β
|
|
Security Β Β
|
|
Β Β Β Β Docs Β Β
|
|
π¦Β Installation
|
β‘Β QuickΒ Start
|
π§ͺΒ ExamplesΒ &Β Tests
|
πΒ Documentation
|
π οΈΒ CodeΒ Standards
|
π§Β DesignΒ Notes
|
π€Β AIΒ Usage
|
βοΈΒ License
|
π₯Β Maintainers
|
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.Attachregistersupdate(andupgrade, its alias) with--check,--force, and--verbose, and wires the passive banner, all from a single config. Drop toNew+AttachBannerwhen 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,fsyncit,chmodit, thenrenameit 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;
cobraenters solely through the optionalcobracmd/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;Installis coming soon β until then it returns a clear message pointing at the releases page instead of failing halfway.
go-selfupdate requires a supported release of Go.
go get -u github.com/mrz1836/go-selfupdateGet the MAGE-X build tool for development:
go install github.com/mrz1836/mage-x/cmd/magex@latestOne 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.
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.
The order matters, and it is part of the contract:
- Platform guard β an unsupported
GOOS/GOARCHis 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. - Managed-install detection β a binary another installer owns (a package manager's cellar, a toolchain
bindirectory) is refused with the command that does own it, rather than silently overwritten. - Writable-directory probe β
install dir not writable: <path>arrives before the download, not after it. - Release resolution β the
ghCLI first when it is present and authenticated, falling back to the GitHub REST API. - 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. - Guarded extraction β path traversal (
../) is rejected, file modes are normalized, and a size cap stops a decompression bomb. - Atomic replace β stage a sibling
<target>.new,fsync,chmod, thenrenameit 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
Installis gated on Windows for now β it returnsErrWindowsNotSupportedwith 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 existsmanaged/ β 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.zreleases 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.
- API Reference β Dive into the godocs at pkg.go.dev/github.com/mrz1836/go-selfupdate
- Design Notes β The choices worth knowing about are in the Design Notes section
- Test Suite β Review the unit tests and fuzz tests, written against the standard library
testingpackage - Examples β Browse the runnable CLI in
examples/minimal
Repository Features
This repository includes 25+ built-in features covering CI/CD, security, code quality, developer experience, and community tooling.
Library Deployment
This project uses goreleaser for streamlined binary and library deployment to GitHub. To get started, install it via:
brew install goreleaserThe 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=masterThis 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 installThe 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.
Updating Dependencies
To update all dependencies (Go modules, linters, and related tools), run:
magex deps:updateThis 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 helpAll 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 --checkThe 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 testRun all tests with race detector (slower):
magex test:raceA 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.
Read more about this Go project's code standards.
Read the AI Usage & Assistant Guidelines for details on how AI is used in this project and how to interact with the AI assistants.
![]() |
|---|
| MrZ |
View the contributing guidelines and please follow the code of conduct.
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! π
