Skip to content

phlipse/go-selfupdate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-selfupdate

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.

Install

go get github.com/phlipse/go-selfupdate@latest

Packages

The module is intentionally split into three reusable layers:

  • manifest: manifest types, signature handling, version checks, and artifact metadata
  • client: resolve a signed release from a client.Source, verify it, and prepare a verified local bundle
  • installer: 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 artifacts
  • cmd/selfupdate-updater: replace a local binary, restart it, and clean up temporary artifacts

Development

make test
make lint
make build

make 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.

Quick Start

1. Generate a signing key once

go run ./cmd/selfupdate-packager \
  -private-key /secure/demo-app-update-key.pem \
  -generate-key

This 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.

2. Package a release

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.gz

Windows 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=zip

Both 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=zip

If 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).

3. Check whether an update should be offered

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.

Channel-switch downgrade policy

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-app
  • client.DowngradeSameMinor: allow downgrades only when major and minor match, for example 1.5.2 -> 1.5.0
  • client.DowngradeSameMajor: allow downgrades anywhere within the same major version, for example 1.5.2 -> 1.4.0
  • client.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 DowngradeNever are only considered when ChannelChanged is true
  • DowngradeAlways allows any lower version once that channel switch was explicitly detected
  • a downgrade outside the selected scope returns CheckStatusNoUpdate while still inside the same major line
  • a downgrade across major versions returns CheckStatusDowngradeBlocked unless DowngradeAlways is selected for that channel switch
  • version comparisons normalize a leading v and ignore suffixes after - or +; channel-specific builds that share the same base version are therefore treated as equal

4. Prepare the bundle locally and start the updater

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()

Release Sources

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(...).

Release Layout

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_version
  • app_id
  • component
  • channel
  • version
  • min_supported_version
  • published_at
  • artifacts

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.

Security Model

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

Platform Behavior

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.

Troubleshooting

No update found even though artifacts exist

  • check that app_id and component match 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

Update is rejected as manual-only

  • min_supported_version is set above the currently installed version
  • this is intended for incompatible updater protocol or security cutovers

Updater starts but does not replace the binary

  • 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

About

go-selfupdate is a small Go module for signed self-updates from pluggable release sources.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors