update

package
v0.34.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package update owns the CLI's knowledge of its own releases: which one is current, which one is newest, how a release archive is named, and how the binary on disk was installed.

Two constraints shape the whole package.

  1. It never carries the Ronja credential. internal/api attaches `Authorization: Bearer <token>` to everything it sends, so a release fetch routed through it would hand a Ronja access token to github.com on every check. This package therefore does NOT import internal/api — a test pins that import boundary, because the invariant is structural and a future convenience refactor is exactly how it would be lost.
  2. The release source is a compiled-in constant. There is no env var that redirects it, so nothing in the environment can decide what bytes `ronja update` downloads and writes over the running binary. Tests reach it through one package var in internal/commands and nowhere else.

Everything here is stdlib-only apart from internal/config, which owns the directory the state file lives in.

Index

Constants

View Source
const (
	// MaxChecksums caps the manifest. Five lines of hex; 64 KiB is generous.
	MaxChecksums = 64 << 10
	// MaxAsset caps the archive and the binary inside it. The real archive is
	// ~8 MB. The cap is what stops a wrong or hostile URL from filling the
	// disk, and it applies to the DECOMPRESSED entry too — a gzip stream is
	// otherwise free to expand without bound.
	MaxAsset = 100 << 20
)
View Source
const (
	// DefaultBaseURL is the GitHub API host the release lookup runs against.
	DefaultBaseURL = "https://api.github.com"

	// Repo is the public mirror the CLI is published to. The monorepo builds
	// the release; the mirror is where it lands and where `go install` and the
	// Homebrew cask both point.
	Repo = "ronjatech/ronja-cli"

	// ChecksumsName is the manifest goreleaser publishes beside the archives
	// (`checksum.name_template: checksums.txt` in .goreleaser.yaml).
	ChecksumsName = "checksums.txt"
)
View Source
const StateFileName = "update-check.json"

StateFileName is the cache beside the credential store. It holds no secret; it exists so a check runs at most once a day and the notice at most once a day, without either needing to reach the network to find that out.

Variables

View Source
var ErrAmbiguousBinaryEntry = errors.New(`the archive carries more than one entry named "ronja"`)

ErrAmbiguousBinaryEntry is returned when an archive carries more than one entry named `ronja`.

Refusing is the safe answer and "the first one wins" is not: a tar file may legally repeat a name, most extractors let the LAST one win, and an archive where the honest binary is followed by another entry of the same name is a shape the real release pipeline never produces. There is no reading of it that is obviously right, so there is nothing to install.

View Source
var ErrBinaryTooSmall = errors.New(`the "ronja" entry in the archive is too small to be the binary`)

ErrBinaryTooSmall is returned when the archive's `ronja` entry is too small to be the binary — see minBinaryBytes.

View Source
var ErrChecksumConflict = errors.New("listed twice in the checksum manifest with different sums")

ErrChecksumConflict is returned when the manifest lists one asset twice with two DIFFERENT sums.

There is no right answer to pick, so there is nothing to install. Taking the first (or the last) would let a manifest carrying both the honest sum and somebody else's decide which one the updater verifies against by ordering alone. Two lines with the SAME sum are not a conflict — that is a duplicated line, and it agrees with itself.

View Source
var ErrChecksumNotListed = errors.New("not listed in the checksum manifest")

ErrChecksumNotListed is returned when the manifest says nothing about the asset — which is a refusal to update, not a checksum failure.

View Source
var ErrNoBinaryEntry = errors.New(`no regular file named "ronja" in the archive`)

ErrNoBinaryEntry is returned when a release archive does not contain the one entry the updater is willing to install.

View Source
var ErrStateNotOwned = errors.New("update state directory belongs to another user")

ErrStateNotOwned is what SaveState reports instead of writing, when the effective uid is 0 and the config directory belongs to somebody else.

Nothing branches on it. Both callers treat every save failure the same way: `ronja update` ignores them all, because a cache it could not update is no reason to refuse an update somebody asked for, and the daily check refuses to go anywhere near the network after any of them — it writes CheckedAt BEFORE the fetch precisely so an unwritable state file costs nothing rather than a request per command. What the sentinel buys is a NAME: a nil return here would have made "root declined to write in your directory" indistinguishable from "written", and the tests that hold that line assert on this identity.

View Source
var Geteuid = os.Geteuid

Geteuid is os.Geteuid, as a var so the sudo refusal below can be tested at all: the case it exists for — root against somebody else's config directory — is otherwise reachable only by running the suite as root against a second user's home, which no test can arrange.

Exported because the daily check's own tests live in internal/commands and need the same seam to prove that ErrStateNotOwned costs no request and prints nothing. Nothing in the shipping code assigns it.

Functions

func AssetName

func AssetName(version, goos, goarch string) string

AssetName is the release archive for one platform.

The `v` is dropped because goreleaser's archive name_template uses `.Version` (`ronja_0.28.4_darwin_arm64.tar.gz`) while the ldflags stamp uses `.Tag` (`v0.28.4`) — see .goreleaser.yaml, where the divergence is deliberate and explained.

func ChecksumFor

func ChecksumFor(manifest []byte, asset string) (string, error)

ChecksumFor finds one asset's SHA-256 in a goreleaser checksums.txt.

The format is `<hex> <name>` with two spaces, but any whitespace run is accepted: the single-space and `*name` (binary-mode) forms are what other producers of the same file emit, and a checksum that fails to parse would be read as "not listed", i.e. as a refusal to update.

The parse is `<hex><whitespace><name-to-end-of-line>`: the hex is the first field and the NAME IS THE WHOLE REMAINDER, trimmed. goreleaser never emits an asset name with a space in it — the template is ronja_{{.Version}}_{{.Os}}_{{.Arch}} — so this is not a case that has to work; splitting on every whitespace run would simply have made a name that did contain one silently unfindable, which reads to a user as "this release has no checksum for your platform".

The whole manifest is read even after a hit, because "listed twice" is only answerable at the end. Hex is compared case-insensitively throughout: the digest is a number, and a producer that writes it in upper case has not said anything different.

A line whose first field is not a SHA-256 digest is not a checksum line at all, and is skipped rather than parsed. That is what keeps a header, a comment, a UTF-8 BOM on the first line, or the BSD-style `SHA256 (name) = <hex>` form from being answered as `expected`: each of those has a first field that is not hex, and returning one would hand the caller a "checksum" it then compares the archive against — which can only ever mismatch, so a manifest with a comment at the top would read to the user as a tampered download. Refusing to see it means the asset is simply not listed (ErrChecksumNotListed), which is the truth. It also means everything this function returns is 64 hex characters, so the mismatch message the caller prints cannot carry a control sequence from the manifest.

func Compare

func Compare(current, latest string) (newer, ok bool)

Compare reports whether latest is newer than current.

ok is false unless BOTH strings are release versions; a caller must treat ok=false as "no opinion", never as "not newer".

func Download

func Download(ctx context.Context, base, url string, limit int64) (*os.File, string, error)

Download streams a URL into a private temporary file, returning that file — still open, rewound to the start — and the SHA-256 of what was written.

The caller gets the open HANDLE rather than a path, and that is the point. Handing back a path would mean the extractor re-opens the archive by name after the hash was taken, leaving a window in which anything that can write the temp directory swaps the verified bytes for its own and the updater installs something nobody checksummed. Reading through the same descriptor that was hashed closes the window: the bytes extracted are the bytes hashed.

The name is chosen by os.CreateTemp, so it is unpredictable and created O_EXCL. A fixed name in a world-writable /tmp is worse than untidy: a symlink pre-seeded there under that name would make this function truncate whatever it pointed at, with the running user's privileges.

On any error the file is closed and removed, so a failed download leaves nothing behind. On success the caller owns both the handle and the name. base is the release source the URL was named by; see Fetch.

func ExtractBinary

func ExtractBinary(archive io.Reader, dest string, mode os.FileMode) error

ExtractBinary writes the archive's `ronja` entry to dest with mode.

It reads from a READER, not from a path, and the reader the caller passes is the same open file Download hashed — see Download for why the API is shaped that way rather than taking a filename.

The archive goreleaser publishes is FLAT: `ronja` alongside README.md, LICENSE and NOTICE, no wrapping directory. So the entry is matched by exact name and everything else is ignored — an archive that carries `ronja` as a directory, a symlink, a hard link, or at a path inside one is refused rather than interpreted, because the only thing this function is allowed to do is put one known file where a running binary is about to be. A second entry claiming the same name is refused outright (ErrAmbiguousBinaryEntry), and one too small to be a Go binary is refused as well (ErrBinaryTooSmall) — see minBinaryBytes for why an empty entry is the worst case rather than a harmless one.

dest is expected to exist already (it is the writability probe created in the binary's own directory), and is truncated in place so the later rename is same-filesystem and therefore atomic.

func Fetch

func Fetch(ctx context.Context, base, url string, limit int64) ([]byte, error)

Fetch reads a URL into memory with a ceiling. Used for the checksum manifest, which is small and has to be complete before anything else happens.

base is the release source the URL was named by — see requireScheme, which both fetchers run before reaching the network. It is a parameter rather than a check the caller makes because a caller that forgot it would download over plaintext and nothing would say so.

func InstalledByHomebrew

func InstalledByHomebrew(exe string) bool

InstalledByHomebrew reports whether exe was installed by the Homebrew cask.

The test is one thing: a `Caskroom` element in the fully resolved path. The cask stages the binary at <prefix>/Caskroom/ronja/<version>/ronja and symlinks <prefix>/bin/ronja to it, so resolving symlinks is what turns the link a user actually runs into the marker.

Deliberately NOT a $HOMEBREW_PREFIX test, and deliberately no `brew` subprocess. On an Intel Mac the prefix is /usr/local, and /usr/local/bin is exactly where somebody who unpacked the release tarball puts the binary — a prefix test would tell them to run `brew upgrade ronja` and brew would answer "Cask 'ronja' is not installed". Caskroom is the only marker that means what it says.

func IsRelease

func IsRelease(v string) bool

IsRelease reports whether v is a version this package can compare — i.e. whether the running binary came from the release pipeline at all.

func Notice

func Notice(current, latest string, method Method) string

Notice is the one line printed when a newer release exists, with the remedy chosen by how this binary was installed.

Versions are rendered exactly as `ronja --version` prints them, leading `v` included, so the two can never appear to disagree.

func ReleasePage

func ReleasePage(version string) string

ReleasePage is the page for one specific release.

func ReleasesPage

func ReleasesPage() string

ReleasesPage is where a human goes when the automated path refuses.

func Safe

func Safe(s string) string

Safe renders a string the release listing chose for a terminal.

A URL out of the release listing was written by whoever published it, and stderr is a terminal that honours control sequences: a URL carrying an ANSI escape or a right-to-left override can redraw the line it is printed on, and every line these URLs appear on is a refusal — one that can be made to read as a success is worse than no message at all.

This is the second half of a rule the version parser already carries: a TAG is admitted to a printed line only by passing validPrerelease, which is a charset test for exactly this reason, so tags and the asset names built from them are printed verbatim. URLs have no such gate, which is why they have this one.

strconv.Quote is the whole implementation because it escapes exactly the runes at issue — Go's unicode.IsPrint excludes the Cf category, so bidi overrides and zero-width characters are escaped alongside the C0 controls — and leaves an ordinary URL readable.

func SaveState

func SaveState(s State) error

SaveState writes the cache atomically — temp file, fsync, rename. Not for secrecy (there is nothing secret here) but because a half-written file read back later is indistinguishable from a corrupt one, and the recovery is another request every single invocation.

The DIRECTORY is deliberately not fsynced, which is where this stops short of what a durable write does: after a crash the rename may not have reached the disk and the file reverts to its previous contents, or to not existing. For this file that is the same outcome as a cache miss — one extra request to the mirror — and paying a directory fsync on every invocation of every command to avoid it would be the expensive half of durability bought for nothing.

It writes nothing under `sudo` on somebody else's config directory, and says so with ErrStateNotOwned: writing there as root leaves a root-owned file in the user's own directory, and every later unprivileged run then fails to read or rewrite it — the user's daily notice would be permanently dead as a side effect of one privileged update.

Types

type Method

type Method string

Method is how the binary on this machine got here, which is what decides the remedy offered when a newer release exists.

const (
	// MethodSwap is a plain file the CLI may replace itself: a release tarball
	// unpacked by hand, or a `go install` build under GOPATH/bin.
	MethodSwap Method = "swap"
	// MethodHomebrew is a cask install. Homebrew owns the file; swapping it
	// would leave brew's manifest describing bytes that are no longer there.
	MethodHomebrew Method = "homebrew"
	// MethodGoInstall is the remedy on a platform with no release asset —
	// Windows, where the release pipeline builds nothing and `ronja update`
	// refuses, but `go install` still produces a working, stamped binary.
	MethodGoInstall Method = "go-install"
)

type Release

type Release struct {
	Version string
	Assets  map[string]string
}

Release is the one release the CLI cares about: its tag and the download URL of every asset attached to it, keyed by asset name.

func Latest

func Latest(ctx context.Context, base, version string) (Release, error)

Latest reports the newest published release on the mirror.

base is the API host (DefaultBaseURL in production; a fake in tests) and version is the running CLI's version, which travels in the User-Agent so a rate-limited or misbehaving client is identifiable from GitHub's side.

Worth knowing, and documented in cli/README.md rather than engineered around: GitHub's `releases/latest` is the most recently CREATED non-prerelease, not the highest semantic version. A re-cut of an older line published after a newer one would be answered here. That matches what `go install ...@latest` and the Homebrew cask do, so all three install paths agree.

type State

type State struct {
	// CheckedAt is when the mirror was last asked, successfully or not.
	CheckedAt time.Time `json:"checkedAt,omitzero"`
	// NotifiedAt is when the user was last told about a newer release.
	NotifiedAt time.Time `json:"notifiedAt,omitzero"`
	// Latest is the newest release known at CheckedAt.
	Latest string `json:"latest,omitempty"`
}

State is what the last check learned.

func LoadState

func LoadState() (State, error)

LoadState reads the cache. A missing file is a first run and a corrupt one is treated the same way: this is a cache, and the only thing losing it costs is one extra request. Surfacing a parse error would turn a stale byte on disk into a failing command.

Jump to

Keyboard shortcuts

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