go-selfupdate is a small Go module for signed self-updates from pluggable release sources.
It ships with a filesystem source that works well for local directories and SMB-mounted shares, and with an HTTP(S) source for signed releases served over HTTPS or plain HTTP inside trusted environments. The same verification and install flow can later be reused for other transports.
For the detailed handoff semantics between the running executable, the staged bundle, and the updater process, see Update Integration. For download, wait, and cleanup timeout behavior, see Timeouts.
go get github.com/phlipse/go-selfupdate@latestThe module is intentionally split into three reusable layers:
manifest: manifest types, signature handling, version checks, and artifact metadataclient: resolve a signed release from aclient.Source, verify it, and prepare a verified local bundleinstaller: build a stable argument set for the updater binary
The repo also contains two commands:
cmd/selfupdate-packager: create or extend a signed release directory with one or more platform artifactscmd/selfupdate-updater: replace a local binary, restart it, and clean up temporary artifacts
make test
make lint
make buildmake lint installs the pinned golangci-lint version into bin-tools/ if it is missing or outdated. make build writes the host packager and updater binaries into release/. Use make distclean to remove build outputs, caches, and the local linter installation.
go run ./cmd/selfupdate-packager \
-private-key /secure/demo-app-update-key.pem \
-generate-keyThis writes:
/secure/demo-app-update-key.pem/secure/demo-app-update-key.pem.pub
The command also prints the public key as Base64. Embed that value in the application build that should trust this update source.
Linux only:
go run ./cmd/selfupdate-packager \
-out-dir /mnt/updates/test/gui \
-app-id github.com/phlipse/demo-app \
-component gui \
-version v14.11.9 \
-channel test \
-private-key /secure/demo-app-update-key.pem \
-artifact platform=linux/amd64,binary=/build/demo-app,updater=/build/demo-app-updater,archive=tar.gzWindows only:
go run ./cmd/selfupdate-packager \
-out-dir /mnt/updates/test/gui \
-app-id github.com/phlipse/demo-app \
-component gui \
-version v14.11.9 \
-channel beta \
-private-key /secure/demo-app-update-key.pem \
-artifact platform=windows/amd64,binary=/build/demo-app.exe,updater=/build/demo-app-updater.exe,archive=zipBoth platforms into the same release directory:
go run ./cmd/selfupdate-packager \
-out-dir /mnt/updates/stable/gui \
-app-id github.com/phlipse/demo-app \
-component gui \
-version v14.11.9 \
-channel stable \
-min-supported-version v10.0.0 \
-private-key /secure/demo-app-update-key.pem \
-artifact platform=linux/amd64,binary=/build/demo-app,updater=/build/demo-app-updater,archive=tar.gz \
-artifact platform=windows/amd64,binary=/build/demo-app.exe,updater=/build/demo-app-updater.exe,archive=zipIf you call the packager multiple times for the same -out-dir, -channel, and -version, it merges the existing signed manifest and adds missing platform artifacts instead of overwriting the whole release. Re-publishing the same platform fails by default; pass -overwrite-existing-artifacts if you intentionally want to replace an already published platform artifact in that manifest. The CLI is the repeatable -artifact flag with platform=<goos>/<goarch>,binary=<path>,updater=<path>,archive=<zip|tar.gz>. Supported archive values are zip and tar.gz (tgz is accepted as an alias).
publicKey, err := manifest.ParsePublicKey(publicKeyBase64)
if err != nil {
return err
}
result, err := client.CheckForUpdate(updateDir, publicKey, client.Criteria{
AppID: "github.com/phlipse/demo-app",
Component: "gui",
}, currentVersion)
if err != nil {
return err
}
switch result.Status {
case client.CheckStatusAvailable:
// Offer result.Release to the user.
case client.CheckStatusManualUpdate:
return errors.New("manual update required")
case client.CheckStatusNoUpdate:
return nil
}By default go-selfupdate never downgrades a running binary. If the selected source offers a lower version, the result is simply CheckStatusNoUpdate.
If your application supports release channels such as stable, beta, and test, you can explicitly choose how conservative downgrades should be when the user switches channels:
policy := client.CheckPolicy{
DowngradePolicy: client.DowngradeSameMajor,
ChannelChanged: channelChanged,
}
result, err := client.CheckForUpdateWithPolicy(updateDir, publicKey, criteria, currentVersion, policy)
if err != nil {
return err
}
switch result.Status {
case client.CheckStatusAvailable:
if result.Downgrade {
// Offer the selected channel version to the user.
}
case client.CheckStatusDowngradeBlocked:
// The selected channel is on an older major version.
// Require a manual reinstall instead of an in-app downgrade.
}go-selfupdate does not infer channel changes on its own. The consuming application must decide whether the user has actually switched channels and pass that state into CheckPolicy.
Available downgrade policies are:
client.DowngradeNever: never downgrade in-appclient.DowngradeSameMinor: allow downgrades only when major and minor match, for example1.5.2 -> 1.5.0client.DowngradeSameMajor: allow downgrades anywhere within the same major version, for example1.5.2 -> 1.4.0client.DowngradeAlways: allow any lower version, including major downgrades, but still only when the user actually switched channels
When a downgrade policy is active:
- all downgrade policies except
DowngradeNeverare only considered whenChannelChangedistrue DowngradeAlwaysallows any lower version once that channel switch was explicitly detected- a downgrade outside the selected scope returns
CheckStatusNoUpdatewhile still inside the same major line - a downgrade across major versions returns
CheckStatusDowngradeBlockedunlessDowngradeAlwaysis selected for that channel switch - version comparisons normalize a leading
vand ignore suffixes after-or+; channel-specific builds that share the same base version are therefore treated as equal
if result.Status != client.CheckStatusAvailable {
return nil
}
if err := client.PrepareAndStartUpdate(result.Release, client.StartOptions{
LogPath: "/path/to/demo-app-update.log",
}); err != nil {
return err
}If the installed executable filename differs from the packaged filename inside the update bundle, set BundledBinaryName to the stable bundle name:
if err := client.PrepareAndStartUpdate(result.Release, client.StartOptions{
ExecutablePath: `C:\Program Files\Demo App\demo-app_14.11.9.exe`,
BundledBinaryName: "demo-app.exe",
}); err != nil {
return err
}For the full naming model, default resolution, and low-level handoff behavior, see Update Integration.
If you need more control between release resolution, local staging, and process launch, you can still use the lower-level helpers:
preparedRelease, err := client.ResolveAndPrepare(updateDir, publicKey, criteria, "")
if err != nil {
return err
}
defer preparedRelease.Cleanup()The built-in filesystem wrappers assume a plain directory on the local filesystem:
release, err := client.ResolveSignedRelease(updateDir, publicKey, criteria)
prepared, err := client.PrepareBundle(release.ArtifactPath, release.Artifact.SHA256, "")That is a good fit for local paths and SMB-mounted shares.
For HTTPS or other remote delivery, use client.HTTPSource or implement your own client.Source:
source := client.HTTPSource{
BaseURL: "https://updates.example.com/stable/gui",
Client: http.DefaultClient,
Header: http.Header{
"Authorization": []string{"Bearer <token>"},
},
}
release, err := client.ResolveSignedReleaseFromSource(ctx, source, publicKey, criteria)
prepared, err := client.PrepareBundleFromRelease(ctx, release, "")If you do not need separate policy checks between resolving the release and downloading the bundle, you can use the convenience API instead:
preparedRelease, err := client.ResolveAndPrepareFromSource(ctx, source, publicKey, criteria, "")
if err != nil {
return err
}
defer preparedRelease.Cleanup()HTTPSource resolves its HTTP client in this order: explicit HTTPSource.Client, client from context via client.WithHTTPClient(...), then http.DefaultClient. That keeps the shared module transport-agnostic while still fitting naturally with token clients, proxy-configured transports, custom timeouts, and custom TLS roots once the application injects its prepared client explicitly. For the full timeout model across download, updater wait, and deferred cleanup, see Timeouts.
Custom Root CAs are configured on the injected http.Client, for example:
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(rootCAPEM) {
return errors.New("append root ca")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.TLSClientConfig = &tls.Config{
RootCAs: pool,
}
httpClient := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
source := client.HTTPSource{
BaseURL: "https://updates.example.com/stable/gui",
Client: httpClient,
}Retry and backoff are also configured on the injected client. A common pattern is to wrap the transport and retry only transient failures:
type retryTransport struct {
base http.RoundTripper
retries int
backoff time.Duration
}
func (t retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
for attempt := 0; ; attempt++ {
resp, err := base.RoundTrip(req)
if attempt >= t.retries || !shouldRetry(resp, err) {
return resp, err
}
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
time.Sleep(t.backoff * time.Duration(attempt+1))
}
}Use that transport inside the http.Client you pass to HTTPSource or inject via client.WithHTTPClient(...).
A packaged release directory looks like this:
<update-root>/<channel>/<component>/
update.json
update.json.sig
demo-app-v14.11.9-linux-amd64.tar.gz
demo-app-v14.11.9-windows-amd64.zip
The exact archive extension is controlled by the packager flags per platform. The example above uses the typical combination tar.gz for Linux and zip for Windows.
The manifest written by the packager contains:
manifest_versionapp_idcomponentchannelversionmin_supported_versionpublished_atartifacts
app_id and component matter as soon as multiple applications or multiple deliverables share the same update source. They prevent one application from accidentally consuming another application's release.
Every update is accepted only if all of the following checks pass:
- the manifest signature matches the embedded Ed25519 public key
- the selected artifact hash matches
sha256 - the manifest matches the expected
app_id - the manifest matches the expected
component - the manifest version is newer than the local version
min_supported_version, if set, still allows in-app update
Recommended operational model:
- use one signing key per application, not one key for every product
- keep the private key only in your release process
- embed the public key into each application build that should trust the source
- publish into a plain release location that clients can read but not modify
Both Windows and Linux are supported.
Common behavior:
- the client never installs directly from the release source; it copies the artifact locally first
- the updater is a temporary helper process
- the updater can restart the application after replacement
- the updater can remove the downloaded archive, extracted temp directory, and itself
Important assumption: The target installation directory must be writable for the current user.
- check that
app_idandcomponentmatch what the client expects - check that the manifest was re-signed after packaging
- check that the local version is actually older than the published version
min_supported_versionis set above the currently installed version- this is intended for incompatible updater protocol or security cutovers
- verify that the installation directory is writable by the current user
- verify that the packaged bundle contains the expected binary and updater names for the target OS