selfupdate

package
v0.4.3 Latest Latest
Warning

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

Go to latest
Published: Oct 13, 2026 License: MIT Imports: 18 Imported by: 0

Documentation

Overview

Package selfupdate replaces the running jaira binary with a published release.

core/release answers "what version am I, and what changed since an older one" — a question the binary can answer entirely from what is embedded in itself. This package answers a different question: "what version is published right now, and how do I become it" — which means touching the network and the filesystem's executable bit. Keeping the two apart keeps the blast radius of a bug in the self-replace path contained to one small package that release.go never has to import.

Index

Constants

View Source
const MaxAge = 24 * time.Hour

MaxAge is how long a cached Check is trusted before it counts as stale.

Variables

This section is empty.

Functions

func AssetName

func AssetName(version, goos, goarch string) string

AssetName returns the archive name goreleaser publishes for a version and platform. This must stay in step with .goreleaser.yaml's default archive name_template — that file names the thing this function must keep predicting.

func Disabled

func Disabled() bool

Disabled reports whether the background release check has been switched off.

func Overridden

func Overridden() []string

Overridden reports which of the two host overrides are in effect, so a command can name them before it replaces a binary with what they served.

func OverrideOf

func OverrideOf(name string) string

OverrideOf reads a remote-host override from the environment, refusing any scheme that is not https or a loopback http address.

These overrides move the trust root, not just the address: checksums.txt is fetched from the same base as the archive it certifies, so pointing them somewhere else means the verification compares an archive against that same host's own digest. The end of that path is Replace() writing over the running binary.

They exist because the test suite serves a whole release from an httptest.Server rather than requiring network access, which is why plain http on a loopback address is still allowed. Everything else must be https: a downgrade to cleartext would leave the one remaining protection — TLS to a host somebody chose deliberately — with nothing behind it.

Overriding is also reported by the commands that act on it, so a value set in a shell profile or a CI job cannot redirect an upgrade silently.

func Path

func Path() string

Path is where the cache file lives: JAIRA_HOME if set, else ~/.jaira, never inside a repository. Which release is published is a fact about the machine, not about a working tree — putting it per-tree would make someone with ten clones run ten checks a day for the same answer.

func PollCache

func PollCache() (latest string, known bool)

PollCache returns the last known "latest published release" answer, refreshing it in the background first if it is stale.

This is the one non-network entry point a consumer should read through instead of composing Read/Stale/Write/SpawnRefresh itself — currently that consumer is the TUI's persistent version indicator, not any CLI command. It never blocks on the network: a stale cache gets touched and handed to a detached child (see SpawnRefresh's own doc comment for why), and this function returns immediately with whatever was already known, which may be nothing at all.

Disabled() short-circuits before touching anything, so an opted-out machine gets neither a spawned refresh nor a reported answer.

known is false exactly when nothing has actually been learned yet — the check is disabled, or the cache is absent/unparseable and no prior write ever recorded a real Latest. A caller must not render "up to date" on a false known: that would assert a fact nobody has checked.

func Replace

func Replace(target string, bin []byte) error

Replace atomically installs bin at target.

A running process on unix keeps its inode open even after the file at its path is replaced, so renaming a new binary over a running one is safe: the process currently executing target keeps running from the old inode until it exits, and the next invocation of the path picks up the new file. There is nothing else to do here.

func SpawnRefresh

func SpawnRefresh() error

SpawnRefresh starts a detached child that refreshes the cache and does not wait for it.

The child gets JAIRA_NO_UPDATE_CHECK=1 as a recursion guard: it must never itself decide the cache is stale and spawn a grandchild. That happens to be the same switch a user flips to turn the whole thing off, so there is one mechanism here, not two.

There is no Wait() — that is the whole point. The parent is about to exit; the child is reparented to init and reaped there. There is deliberately no attempt to setsid: that needs per-OS SysProcAttr and buys only immunity from a Ctrl-C sent to the process group, and losing a best-effort release check to a Ctrl-C costs nothing — the cache stays stale and the next command retries.

Stdio goes to os.DevNull rather than being inherited, because a child writing to the parent's terminal after the parent has already exited is the exact failure this whole design exists to avoid.

func Stale

func Stale(c Check, ok bool) bool

Stale reports whether c should be refreshed: an absent or unparseable cache (ok == false) always is, and so is one whose CheckedAt is more than MaxAge old.

func Sweep

func Sweep(target string) int

Sweep is a no-op on unix. There is nothing to sweep because Replace never needs to rename anything out of the way before it can write — unix simply does not need this. It takes the target, not a directory, so the Windows build can anchor its pattern to jaira's own name.

func Write

func Write(c Check) error

Write records c, through a temp file in the same directory plus a rename — the same atomic-write idiom the rest of this project uses for anything it cares about.

Types

type Check

type Check struct {
	CheckedAt time.Time `json:"checked_at"`
	Latest    string    `json:"latest"`
}

Check is what gets cached: the answer to "what is the latest published release", and when that answer was last obtained.

func Read

func Read() (Check, bool)

Read returns the cached Check, or (Check{}, false) on any error. A missing, truncated or garbage cache is indistinguishable from "never checked" for every purpose this serves, so every error is swallowed here the same way release.Stamped swallows its own — a best-effort informational cache must never be able to fail a command.

type Client

type Client struct {
	HTTP *http.Client
}

Client talks to the release host.

func New

func New() *Client

New returns a Client with an explicit timeout. The zero-value http.Client has no timeout at all, so a hung TLS handshake would hang the command forever — and this command sometimes runs detached (see core/selfupdate's cache.go), where nobody would ever see it stuck.

func (*Client) At

func (c *Client) At(ctx context.Context, tag string) (Release, error)

At resolves an explicit tag or version to a Release without calling the API at all. It normalizes the tag (adding a leading "v" if the caller gave a bare version), then confirms the release exists by fetching checksums.txt for it — mapping a 404 to a "no such release" error.

This skips releases/tags/<tag> deliberately: that would be a second GitHub payload shape to model and to keep working, when the artifact this actually needs already proves the release exists.

func (*Client) Binary

func (c *Client) Binary(ctx context.Context, rel Release, goos, goarch string) ([]byte, error)

Binary downloads, verifies and extracts the jaira executable for rel on the given platform. Nothing is written to disk anywhere in this function: a verification failure must not be able to leave a partial file that some other code path could pick up. The order below — checksums first, then the archive, then the hash comparison, then extraction — is the security property itself: no byte of the archive is ever interpreted before it is known to be the byte-for-byte published one.

func (*Client) DownloadURL

func (c *Client) DownloadURL(tag, asset string) string

DownloadURL returns the address of a named asset in a tagged release.

func (*Client) Latest

func (c *Client) Latest(ctx context.Context) (Release, error)

Latest fetches the most recently published release from the API.

type Kind

type Kind string

Kind classifies how the running binary got onto this machine, which decides whether 'jaira self upgrade' is allowed to touch it.

const (
	// SelfManaged means nothing else claims to own this file; upgrading it
	// in place is safe.
	SelfManaged Kind = "self-managed"
	// Managed means a package manager (Homebrew) owns this file.
	Managed Kind = "homebrew"
	// GoInstall means 'go install' put this file where it is.
	GoInstall Kind = "go-install"
	// Unwritable means neither of the above applies, but the directory
	// cannot be written to anyway.
	Unwritable Kind = "unwritable"
)

func Detect

func Detect(target string) (Kind, string)

Detect classifies target and, for anything other than SelfManaged, returns the instruction to print instead of upgrading.

This exists because replacing a file a package manager owns leaves that manager's database lying about what is installed — its next operation (a `brew upgrade`, a `go install` of something else) will silently step on or contradict what this command just did. Refusing is the correct behavior here, not a limitation: `uv self update` draws exactly this same line for the same reason.

The checks run in this order — Homebrew, then go-install, then writability — because ownership is a stronger fact than writability. A Homebrew prefix is usually writable by its owning user, so checking writability first would misclassify a Homebrew-owned file as merely self-managed-and-writable.

type Release

type Release struct {
	Tag     string
	Version string
}

Release identifies one published release.

Tag carries the leading "v" — GitHub tags do, and DownloadURL's path needs it. Version does not — release.Current and the archive name never have one. Getting the two backwards is the obvious bug here, which is exactly why they are two named fields instead of one string reused two ways.

Jump to

Keyboard shortcuts

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