selfupdate

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 18 Imported by: 1

README

go-selfupdate

Install a newer build of the running program from a GitHub release, then hand the process back to systemd.

Built for services that ship as a tarball holding an executable and some directories beside it (a built frontend, say), running under a unit with Restart=always. Installing swaps the files atomically and raises SIGTERM; systemd starts the new version.

updater, err := selfupdate.New(selfupdate.Config{
    Owner:          "FiretailHosting",
    Repo:           "my-service",
    Token:          token, // only needed for a private repo
    BinaryName:     "my-service",
    CurrentVersion: version.Version, // stamped with -ldflags
    Dirs:           map[string]string{"frontend": "index.html"},
    ExpectedUnit:   embeddedUnit, // optional, see "Host wiring"
})

result, err := updater.InstallLatest(ctx)
if err != nil {
    return err
}

// answer the request first, then go
selfupdate.Restart(time.Second)

What it does

InstallLatest fetches the newest release, refuses anything not newer than CurrentVersion, downloads the asset for this platform, and swaps it in.

Nothing on disk changes unless the whole bundle verifies first:

  • the executable exists, is non-empty, and starts with the ELF magic number — a 404 page or a wrong-architecture build installed under Restart=always is an endless crash loop, and this is the cheapest way to catch one
  • every directory in Dirs contains its named sentinel file

The swap then moves the old files aside and renames the new ones in. Staging and backup both live inside the install directory, so every move is a same-filesystem rename and nobody ever observes half a file. If any step fails, the previous files go back.

Directories are replaced, not merged. Merging leaves the last release's hashed assets lying around forever.

Safety

  • Archive entries that climb out of the staging directory are rejected, and symlinks, hard links and devices are skipped rather than written.
  • setuid/setgid bits in tar headers are stripped; a zero mode becomes 0644.
  • Downloads are capped (512 MiB by default) and both HTTP clients have timeouts.
  • The token is removed when a redirect crosses hosts. GitHub answers an asset request with a redirect to signed storage that needs no token and should never see one. net/http does something similar but only compares hostnames, so a same-host different-port hop keeps the header.

Restarting

Restart raises SIGTERM rather than calling os.Exit, so the program's own shutdown runs. For anything holding a database, skipping that risks leaving the write-ahead log unflushed. Call it after the response is on its way out.

Supervised() reports whether systemd started the process, which is what makes "exit and come back on the new build" work at all.

Host wiring

Self-update replaces the executable and the directories in Dirs. It does not touch the systemd unit, sudoers rules or file permissions — a service that could rewrite its own systemd configuration could grant itself anything.

So a release that needs new host wiring lands on a machine still running the old unit, and whatever it added silently does not work. Embed the unit you ship and pass it as ExpectedUnit:

//go:embed my-service.service
var embeddedUnit string

Status() then reports UnitStale, and Status().Problems() returns the message to show: re-run the installer from this release.

Status() also reports whether the install directory is writable, and why not when it isn't. "Permission denied" and "read-only file system" look the same to an operator but have different fixes, and the second is invisible from a shell because it comes from the service's own systemd mount namespace.

Asset naming

The default is <binary>_<tag>_<goos>_<goarch>.tar.gz, matching what a make package target usually produces. Override Config.AssetName for a different convention.

Requirements

Linux with systemd, and a release whose asset is a gzipped tarball with the executable and the Dirs at its root. Go 1.23+.

License

MIT.

Documentation

Overview

Package selfupdate installs a newer build of the running program from a GitHub release, then hands the process back to its supervisor.

It is built for a service that ships as a tarball containing an executable and some directories beside it (a built frontend, say), installed under a systemd unit with Restart=always. Installing swaps the files atomically and raises SIGTERM; systemd starts the new version.

The package deliberately does not manage the systemd unit, sudoers rules or directory permissions. A service has no business granting itself privileges, so those stay with whatever installer put the service there. UnitState exists to notice when that wiring has fallen behind the running build.

Index

Constants

View Source
const StaleUnitProblem = "This build expects a different systemd unit from the one installed. " +
	"Self-update replaces only the executable and its data directories, never the unit, " +
	"the sudoers rules or file permissions: re-run the installer from this release to apply them."

StaleUnitProblem is the advice to show when the installed unit is out of date.

Variables

This section is empty.

Functions

func Compare

func Compare(a, b string) int

Compare orders two semver-ish tags, returning -1, 0 or 1.

Tags may carry a leading "v" and a prerelease or build suffix. The suffix is only a tiebreaker: 1.2.0 outranks 1.2.0-rc1.

func DefaultAssetName

func DefaultAssetName(binary, tag string) string

DefaultAssetName matches the convention `make package` produces: <binary>_<tag>_<goos>_<goarch>.tar.gz.

func InstallDir

func InstallDir() (string, error)

InstallDir returns the directory holding the running executable, with symlinks resolved so an update replaces the real file rather than a link.

func Newer

func Newer(candidate, current string) bool

Newer reports whether release tag "candidate" is later than "current".

A build with no version ("" or "dev") counts as older than everything, so a developer build offers the newest release instead of claiming to be current.

func Restart

func Restart(delay time.Duration)

Restart hands the process back to its supervisor after the given delay.

It raises SIGTERM rather than exiting, so the program's own shutdown runs: for anything holding a database, skipping that risks leaving the write-ahead log unflushed. Call it after the response is on its way out.

It returns immediately; the signal is raised from a goroutine.

func Supervised

func Supervised() bool

Supervised reports whether systemd started this process.

Types

type Asset

type Asset struct {
	ID   int64  `json:"id"`
	Name string `json:"name"`
	Size int64  `json:"size"`
}

Asset is a file attached to a release.

type Config

type Config struct {
	// Owner and Repo identify the GitHub repository holding the releases.
	Owner string
	Repo  string
	// Token authenticates against a private repository. Public repos need none.
	Token string

	// BinaryName is the executable's name, both inside the release tarball and
	// in the install directory.
	BinaryName string

	// CurrentVersion is the running build's version, normally stamped in with
	// -ldflags. "dev" or empty counts as older than any release.
	CurrentVersion string

	// Dirs are directories in the tarball that replace their counterparts in the
	// install directory wholesale. Each value names a file that must exist
	// inside, as a sanity check against a half-built bundle:
	//
	//	Dirs: map[string]string{"frontend": "index.html"}
	//
	// Replacing rather than merging matters: a merge leaves the previous
	// release's hashed assets behind forever.
	Dirs map[string]string

	// AssetName builds the release asset's file name from a tag. It defaults to
	// DefaultAssetName, which matches <binary>_<tag>_<goos>_<goarch>.tar.gz.
	AssetName func(tag string) string

	// InstallDir defaults to the directory holding the running executable.
	InstallDir string

	// MaxAssetBytes caps the download. Defaults to 512 MiB.
	MaxAssetBytes int64

	// ExpectedUnit is the systemd unit this build ships, normally embedded with
	// go:embed. When set, Status reports whether the installed unit matches.
	ExpectedUnit string

	// UnitPath defaults to /etc/systemd/system/<BinaryName>.service.
	UnitPath string

	// APIBase overrides the GitHub API root, for tests.
	APIBase string

	// UserAgent is sent with every request. Defaults to the binary name.
	UserAgent string

	// APITimeout and DownloadTimeout default to 30 seconds and 30 minutes.
	APITimeout      time.Duration
	DownloadTimeout time.Duration

	// HTTPTransport is shared by both clients when set.
	HTTPTransport http.RoundTripper
}

Config describes the program being updated and where its releases live.

type Release

type Release struct {
	TagName    string  `json:"tag_name"`
	Name       string  `json:"name"`
	Draft      bool    `json:"draft"`
	Prerelease bool    `json:"prerelease"`
	Published  string  `json:"published_at"`
	Assets     []Asset `json:"assets"`
}

Release is the subset of a GitHub release this package needs.

func (Release) FindAsset

func (r Release) FindAsset(name string) (Asset, bool)

FindAsset returns the asset with this exact name.

type Result

type Result struct {
	// Version is the tag that was installed.
	Version string
	// Asset is the release asset it came from.
	Asset string
	// Replaced lists the paths swapped in the install directory.
	Replaced []string
}

Result describes a completed install.

type Status

type Status struct {
	InstallDir string
	// Writable is false when the install directory cannot be written.
	Writable bool
	// WriteProblem explains why, when it cannot.
	WriteProblem string
	// Supervised is true when systemd started this process, which is what makes
	// "exit and come back on the new build" work.
	Supervised bool
	// Unit reports whether the installed systemd unit matches this build, when
	// Config.ExpectedUnit is set.
	Unit UnitState
}

Status is what the host allows right now. A UI can use it to explain why updating is unavailable instead of failing when the button is pressed.

func (Status) CanInstall

func (s Status) CanInstall() bool

CanInstall reports whether an update would work here.

func (Status) Problems

func (s Status) Problems() []string

Problems lists everything standing between this host and a working update.

type UnitState

type UnitState int

UnitState says whether the installed systemd unit matches this build.

const (
	// UnitUnknown means there was nothing to compare: no expected unit
	// configured, or no unit installed. This is the normal case in development.
	UnitUnknown UnitState = iota
	// UnitCurrent means the installed unit matches this build.
	UnitCurrent
	// UnitStale means this build expects a different unit from the installed one.
	UnitStale
)

type Updater

type Updater struct {
	// contains filtered or unexported fields
}

Updater installs releases of one program.

func New

func New(cfg Config) (*Updater, error)

New validates the configuration and returns an Updater.

func (*Updater) AssetNameFor

func (u *Updater) AssetNameFor(tag string) string

AssetNameFor is the asset this build looks for in a given release.

func (*Updater) Available

func (u *Updater) Available(ctx context.Context) (Release, bool, error)

Available reports the latest release and whether it is newer than this build.

func (*Updater) CheckBundle

func (u *Updater) CheckBundle(r io.Reader) error

CheckBundle verifies a release bundle without installing anything.

Worth running in CI against whatever `make package` produced: it is the only thing that catches the packaging and the updater drifting apart, which otherwise shows up for the first time on a device, mid-update.

func (*Updater) Install

func (u *Updater) Install(ctx context.Context, release Release) (Result, error)

Install downloads a specific release and swaps it into place.

func (*Updater) InstallLatest

func (u *Updater) InstallLatest(ctx context.Context) (Result, error)

InstallLatest downloads the newest release and swaps it into place.

It does not restart anything: the caller answers its HTTP request or logs a line first, then calls Restart. Nothing is changed on disk unless the whole bundle verifies, and a failure part-way through restores the previous files.

func (*Updater) Latest

func (u *Updater) Latest(ctx context.Context) (Release, error)

Latest returns the repository's latest published release.

GitHub's "latest" endpoint ignores drafts and prereleases, so a release marked prerelease is invisible here by design.

func (*Updater) Status

func (u *Updater) Status() Status

Status inspects the host.

func (*Updater) UnitState

func (u *Updater) UnitState() UnitState

UnitState compares the installed systemd unit with the one this build expects.

Self-update cannot change the unit, because a service that could rewrite its own systemd configuration could grant itself anything. So a release needing new host wiring lands on a machine still running the old unit, and whatever it added silently does not work. Embedding the expected unit and comparing is how that gets noticed rather than debugged.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL