Ship a CLI that updates itself — without shipping a supply-chain hole.
Three calls in main(): your tool checks GitHub Releases, verifies what it
downloads, and atomically swaps its own binary.
No dependencies. No re-exec. No surprises on Windows.
Quick start · Release setup · Layouts · Channels · Signing · Hardening · Testing
Note
Young library. The API is settled enough to build on but may move before v1. Issues welcome. 🙌
go get github.com/p-arndt/selfupdateup, err := selfupdate.New(selfupdate.Config{Owner: "you", Repo: "mytool"})
selfupdate.CleanupLeftovers() // once at startup
up.NotifyIfAvailable(os.Stderr, version) // the passive hint
up.Run(ctx, os.Stdout, version, false) // your `update` subcommandThat's the whole integration. Owner and Repo are the only required fields.
What users see:
$ mytool status
... your tool's output ...
A newer mytool is available: 1.4.0 (you have 1.2.0). Run `mytool update` to upgrade.
$ mytool update
Current version: 1.2.0. Checking for updates…
Updated mytool 1.2.0 → 1.4.0.The hint goes to stderr, so mytool status | jq stays clean.
The three calls in detail
| Call | When | Notes |
|---|---|---|
CleanupLeftovers() |
Once at startup | Package-level, no Updater needed. Removes the <exe>.old file a previous Windows update left. Never errors. |
NotifyIfAvailable(w, current) |
Late in a normal run | Prints the cached result, then refreshes for next time. Never blocks >1.5s. Silent on dev builds and when the opt-out env var is set. |
Run(ctx, w, current, checkOnly) |
Your update subcommand |
Prints progress, installs, reports. Returns an error; you own the exit code. ctx lets Ctrl-C interrupt a download; without a deadline it gets UpdateTimeout (60s). |
current takes your version with or without a leading v. "", "dev" and
"(devel)" are treated as source builds and never updated over — a local build
is usually ahead of the last tag.
A runnable version lives in examples/minimal.
- 🔒 Verified downloads — SHA-256 against the release checksums, before anything touches your binary
- ✍️ Optional signing — Ed25519 over the checksums, with key rotation that doesn't brick shipped binaries
- 🪟 Windows-correct — a running
.execan't be overwritten, so it's renamed aside and cleaned up next run - ⚡ Free on the hot path — the notice reads a cache; the network refresh is bounded at 1.5s, once a day
- 🧪 Beta channel — opt users into pre-releases and back out again, with the rollback that implies
- 📦 Two release layouts — archives or raw binaries, or bring your own naming
- 🧊 Zero dependencies — standard library only, enforced by CI
- 🧪 Testable — three
Configseams point the whole flow athttptestand a temp dir - 🚫 Opinionated refusals — no downgrades, no dev-build clobbering, no plain HTTP, no truncated installs
The library finds nothing unless your release publishes these names. Repo
mytool, tag v1.2.0:
mytool_1.2.0_linux_amd64.tar.gz ← contains a file named "mytool"
mytool_1.2.0_darwin_arm64.tar.gz
mytool_1.2.0_windows_amd64.zip ← contains "mytool.exe"
mytool_1.2.0_checksums.txt
mytool_1.2.0_checksums.txt.sig ← only when signing
Checksums are plain sha256sum output, hashing the archive, not the binary
inside it:
9f2c… mytool_1.2.0_linux_amd64.tar.gz
The five things that trip people up:
- The tag carries
v, the file names don't.v1.2.0→ assets say_1.2.0_. The most common mistake by far. - Stamp the version in:
-ldflags "-X main.version=1.2.0". Without it your build reportsdevand the updater refuses to touch it. - Set
AppNameif the binary isn't named after the repo — one field, not six. - Signing is all-or-nothing. With a verifier set, a release without a
.sigis an error, never a fallback. - Tags must match
^[0-9][0-9A-Za-z.+-]{0,63}$oncevis stripped, or the release is rejected.
Also: pre-release tags (v1.2.0-rc.1) never appear in GitHub's releases/latest,
so they're never offered — unless the user is on the prerelease
channel. A missing platform asset gives a clear error, not a
silent skip.
For Config{Owner: "you", Repo: "mytool"} — all derived from AppName, which
defaults to Repo:
| Default | |
|---|---|
| Asset name | mytool_<version>_<goos>_<goarch>.tar.gz (.zip on Windows) |
| Binary inside | mytool (mytool.exe on Windows) |
| Checksums file | mytool_<version>_checksums.txt |
| Upgrade hint | mytool update |
| Opt-out variable | MYTOOL_NO_UPDATE_CHECK |
| State cache | <os.UserConfigDir>/mytool/update-check.json (channel choice lives here too) |
| Release channel | stable — full releases only |
| User agent | mytool-updater |
| Check interval / timeouts | 24h · 1.5s notice · 60s update |
Tip
Binary not named after the repo? Set AppName and stop — asset names,
checksums file, opt-out variable, state dir, user agent and hint text all
follow it. Repo compose-check-updates shipping ccu needs
AppName: "ccu", not six separate overrides.
Every field is overridable on Config.
| Layout | Produces | Use when |
|---|---|---|
&layout.Archive{} (default) |
mytool_1.2.0_linux_amd64.tar.gz |
goreleaser-style, binary inside an archive |
&layout.RawBinary{} |
mytool-linux-amd64 (.exe on Windows) |
your README's install one-liner curls that exact name |
Neither fits? Override the names instead of forking:
Layout: &layout.Archive{
Name: func(version, goos, goarch string) string { ... },
BinaryName: func(goos string) string { ... },
},
ChecksumsName: func(version string) string { ... },Or implement layout.Layout — three methods.
Two channels, both installing from the same releases and verified the same way — they differ only in which releases are eligible:
| Channel | Sees | Endpoint |
|---|---|---|
Stable (default) |
full releases only | /releases/latest — GitHub excludes pre-releases and drafts for you |
Prerelease |
every published release, pre-releases included, highest version wins | /releases |
Prerelease is a superset, not a parallel track: when 1.5.0 finally ships it
outranks 1.5.0-rc.2, so beta users move to the final release like everyone else
instead of getting stranded on release candidates.
// `mytool update --beta` / `mytool update --stable`
ch, err := selfupdate.ParseChannel(*channelFlag) // "", "stable", "beta", "prerelease"
if err != nil {
return err
}
err = up.RunSwitch(ctx, os.Stdout, version, ch)$ mytool update --beta
Switching mytool to the prerelease channel (currently 1.4.0)…
Updated mytool 1.4.0 → 1.5.0-beta.1 on the prerelease channel.
$ mytool update --stable
Switching mytool to the stable channel (currently 1.5.0-beta.1)…
Rolled mytool back 1.5.0-beta.1 → 1.4.0, the latest stable release.The choice is remembered in the state file, so plain mytool update and the
passive notice follow it from then on — and only once the switch actually landed,
so a failed download never leaves the updater tracking a channel the binary isn't
on.
| Call | Does |
|---|---|
up.Channel() |
the channel in effect: the user's choice, else Config.Channel |
up.SetChannel(ch) |
remember a choice, no network, no install |
up.SwitchChannel(ctx, current, ch) |
switch + install, returns a *Result |
up.RunSwitch(ctx, w, current, ch) |
the same, with human-readable output |
Ship betas by default (a nightly build of the same tool) with
Config{Channel: selfupdate.Prerelease} — a user's own choice still outranks it.
Important
Switching channels is the only path allowed to install an older version;
leaving beta while running 1.5.0-beta.2 has to mean going back to 1.4.0, or
the user is stuck until the final ships. Plain Run/SelfUpdate stays strictly
forward-only, so the network can never talk you into a downgrade.
Checksums only prove the download wasn't corrupted in transit. They travel over the same channel as the binary, so anyone who can edit release assets regenerates both and verification still "passes". Signing anchors trust in a key that never lives in the repo or the release.
Verifier: &verify.Ed25519{
Keys: []string{"sQrabBts6F9SlNhvnwFw5HRHS8xHHM92frEJKpctvd4"},
Domain: "mytool release checksums v1",
},- Unsigned release → refused outright. Never downgraded to a warning.
- Replay-proof. The signed message includes the checksums file's name, which carries the version, so an old signed release can't reappear under a newer tag.
- Cheap to reject. Signature presence is checked against metadata already in hand, then the small files are verified — a bad release never costs you a 64 MiB download.
- Fails closed. An empty or all-garbage key list refuses everything; it never becomes "no key, so skip".
Sign at release time with the same struct — v.Sign(privateKey, checksumsName, content)
— and upload the result as <checksums name>.sig. See examples/signed.
Warning
Keys is a list so you can rotate. A binary only trusts the keys it was
compiled with, so swapping the sole key makes every shipped binary reject every
new release — permanently, unfixably. Instead: add the successor, ship
releases signed by it while both are trusted, drop the old one once users have
upgraded.
latest := up.Refresh(version) // "" when current, disabled, or unreachableUnlike NotifyIfAvailable, this refreshes a stale cache and reports what's true
now. Blocks up to 1.5s — call it off the UI thread.
Deliberate, load-bearing refusals. If one is in your way, the answer is almost never to remove it.
- https-only, GitHub hosts only — redirects included. A checksum only vouches for a download if the whole chain is authenticated. Loopback may use plain http, so tests can serve locally.
- Release tags validated at ingress — leading digit,
[0-9A-Za-z.+-], ≤64 bytes. Tags reach your terminal, file names and cache: no ANSI escapes, no../, no homoglyphs. - Size caps error, never truncate — including what an archive inflates to. A truncated binary bricks an install as thoroughly as a malicious one.
- Only strictly newer versions install — an attacker controlling the network can stall you, but can't serve an older, known-vulnerable release as an "update". The single exception is an explicit
SwitchChannel, where going back is what the user asked for. - Dev and source builds are never touched.
- Errors never echo server text — not the URL, not the HTTP status line.
- The new binary is not re-executed — the process keeps the code it loaded.
- The cache is re-validated on load — it's attacker-influencable at rest.
- Nothing large is fetched before it's trusted.
Three Config seams run the whole flow offline:
| Seam | Point it at |
|---|---|
APIBase |
an httptest server instead of api.github.com |
StatePath |
a temp file, so tests never touch the real cache |
ExecutablePath |
a throwaway file, so an install doesn't overwrite your test binary |
cfg := selfupdate.Config{
Owner: "you", Repo: "mytool",
APIBase: srv.URL,
StatePath: func() (string, error) { return filepath.Join(t.TempDir(), "c.json"), nil },
ExecutablePath: func() (string, error) { return throwaway, nil },
}Take a Config in your own constructor so production passes the zero value and
tests pass their seams. examples/testing is a complete,
runnable version — copy tool_test.go and change the names.
| Package | What's in it |
|---|---|
. (selfupdate) |
Config, New, Updater — what you integrate against |
layout |
How release assets are named and packed |
verify |
Optional Ed25519 signature verification |
version |
Semver ordering and the ingress guard |
internal/… |
GitHub client, checksums, the atomic swap, the cache |
Most integrations import only the root package. Integration notes for coding
agents live in llm.txt.
just ci # vet + gofmt + race + dependency gate
just test # go test ./...
just cover # per-package coverageCI runs on Windows, macOS and Linux — the executable swap takes a different path on Windows. It also fails the build if a third-party dependency ever appears: a self-updater with a dependency tree is a supply-chain surface sitting directly on the code path that replaces the user's binary.