Skip to content

feat(version): zcli upgrade self-updater + version checks#259

Open
l-hellmann wants to merge 11 commits into
mainfrom
version-self-update
Open

feat(version): zcli upgrade self-updater + version checks#259
l-hellmann wants to merge 11 commits into
mainfrom
version-self-update

Conversation

@l-hellmann
Copy link
Copy Markdown
Collaborator

@l-hellmann l-hellmann commented May 20, 2026

What Type of Change is this?

  • New Feature
  • Fix
  • Improvement
  • Release
  • Other

Description (required)

Adds zcli upgrade, a self-updater for the CLI, plus version-check UX.

  • zcli upgrade downloads the target release binary, verifies its sha256 against the release checksums.txt, and atomically swaps the running binary.
  • zcli upgrade --check prints current vs latest and exits 0 (up to date) / 1 (behind) / 2 (error).
  • A non-blocking, disk-cached version-mismatch warning is shown before commands, with channel-aware update hints.
  • Install-method detection (manual / npm / brew / nix / deb): self-update is refused for package-managed installs, which instead point at the correct update command. The channel is stamped per goreleaser artifact (raw=manual, npm=npm, deb=deb); path detection is only a fallback.
  • Release pipeline: goreleaser now emits checksums.txt (the name the updater expects) and stamps version.channel per artifact.
  • Tooling: drops the devel build tag so integration tests run in the normal suite; adds in-process integration tests for zcli upgrade.

Demo

zcli upgrade (self-update on a manual install):

upgrade

Package-manager install refuses self-update:

package-manager

Related issues & labels (optional)

  • Closes #

l-hellmann added 11 commits May 20, 2026 21:57
- Trim apiDto.go to only the fields actually consumed (TagName,
  PublishedAt, Asset.Name, Asset.BrowserDownloadUrl).
- Guard the package-level fetch cache with sync.Once.
- Propagate real fetch errors instead of returning a "v0.0.0" sentinel
  that caused a false-positive "update available" warning whenever the
  version API was unreachable.
- Compare versions via golang.org/x/mod/semver. Non-semver current
  values (notably the "local" default for dev builds) no longer trigger
  the warning.
- Add tests for the new comparison helper.
…l tag

- 24h on-disk cache for the version API response, stored next to
  cli.data. fetch() now reads cache first, falls back to network, and
  serves a stale cache if the network is unreachable.
- Install-method detection: Detect() reports nix/npm/brew/manual based
  on the running binary's path, with a build-time channel override via
  ldflags (-X .../version.channel=<name>) for packagers whose install
  paths collide with manual installs (AUR, winget MSI).
- Drop the devel build tag. Non-semver version values ("local") cause
  IsVersionCheckMismatch and PrintVersionCheck to short-circuit at
  runtime, replacing the no-op stubs in version_devel.go.
…e hint

- MismatchWarning() reads the on-disk cache only — the synchronous check
  on every command no longer blocks on the network. The cache is
  populated by a fire-and-forget RefreshCacheIfStale goroutine spawned
  alongside the check, so the next invocation has fresh data.
- One-line warning replaces the two-line template, and the upgrade hint
  is now channel-aware: npm/brew/nix users see their package manager's
  command, install.sh users see the github releases URL.
- writeCacheEntry now writes via a tmp file + rename so a process exit
  during the background refresh can't leave a half-written cache.
- Collapse IsVersionCheckMismatch + GetVersionCheckMismatch into a
  single MismatchWarning(). Drop the embedded message.txt template.
Adds an interactive self-update command for binaries that weren't
installed through a package manager.

  zcli upgrade           # interactive: prompt, download, verify, swap
  zcli upgrade --yes     # skip the confirmation
  zcli upgrade --check   # exit 0 = up to date, 1 = behind, 2 = error
  zcli upgrade --version vX.Y.Z   # install a specific tag (incl. downgrade)

Behavior:
- Refuses package-managed installs (npm/Nix/brew) with the channel-
  specific upgrade command.
- Downloads the release binary and checksums.txt from GitHub, verifies
  sha256 against the manifest, and atomically swaps via the
  minio/selfupdate library (handles the Windows rename-on-exit dance).
- Surfaces permission errors with a re-run-as-sudo hint instead of a
  generic failure.

Side cleanup:
- Unify release asset naming into a single assetName() helper. Fixes a
  pre-existing bug where GetLatestUrl produced "zcli-windows-amd64" or
  "zcli-linux-386" instead of "zcli-win-x64.exe" / "zcli-linux-i386".
- Manual-install upgrade hint in the passive warning now says
  "Run: zcli upgrade" now that the command exists.
- `zcli upgrade --check` no longer refuses package-managed installs.
  Splits PlanUpgrade (always succeeds) from RequireSelfUpdatable (the
  channel guard), which the cmd only calls after --check has had its
  chance to report status.
- Make `releasesURL` and the `selfupdate.Apply` call swappable
  package-level vars so tests can drive the full download + verify path
  against an httptest server without replacing the test binary.
- Add end-to-end Upgrade tests: happy path (verifies the manifest ->
  asset -> checksum wiring), missing asset, manifest 500, binary 404,
  and the permission-denied -> sudo hint.
- Wrap the actual download + verify + swap call in
  uxHelpers.ProcessCheckWithSpinner so the interactive flow shows a
  running spinner with friendly success/failure messages.
- go.mod: minio/selfupdate promoted to a direct dep (now referenced by
  upgrade.go and the new tests).
The self-updater (zcli upgrade) fetches the release asset literally named
checksums.txt to verify downloaded binaries. goreleaser defaults to
zcli_<version>_checksums.txt, so pin the name_template to match. Replaces
the manual checksums job that targeted the pre-goreleaser release pipeline.
main's lint config is stricter than the branch was developed against:
- rename the cached fetch error var fetchErr -> errFetch (errname)
- list InstallManual explicitly in the String/Hint switches (exhaustive)
- collapse the applyUpdate lambda to a direct selfupdate.Apply reference
  and drop the now-unused io import (gocritic unlambda)
Add an InstallDeb method and stamp version.channel via ldflags for every
goreleaser-built artifact: raw=manual (self-updatable), npm=npm, deb=deb.
This requires splitting the previously-shared raw/npm builds since they ship
the same code under different channels. Path detection in src/version is now
only a fallback for builds that aren't stamped (plain go build, brew, nix).
A dpkg-installed zcli now refuses self-update and points at the .deb instead.
Add errorsx.ExitError, recognized by RunRootCmd's error handler, so a command
can signal a specific process exit code by returning instead of calling
os.Exit. upgrade --check now returns ExitError(1)/ExitError(2) rather than
os.Exit, preserving the documented 0/1/2 contract while staying testable
in-process (os.Exit would kill the test runner).
Resolve the version API endpoint at call time, honoring ZEROPS_VERSION_API_URL
so tests (and mirrors) can point it elsewhere without rebuilding. Drop the
package-level sync.Once/latestResponse memoization: fetch() now relies on the
on-disk cache for within-invocation dedup, which removes the global mutable
state and the test-only reset hook it required.
The devel tag no longer changed any build (version_devel.go was removed
earlier), it only gated the integration tests out of the normal suite. Drop
it entirely: remove //go:build devel from the harness and integration tests,
the -tags devel flags from the Makefile and CI, and the related doc mentions.

Add in-process integration tests for `zcli upgrade` covering --check (0/1/2),
explicit --version, and the already-up-to-date path, driven through the
harness with the version API stubbed via ZEROPS_VERSION_API_URL.

Un-tagging exposed pre-existing lint debt in the test files (these were never
linted before): drop the unused ctx param from the harness Run (clearing the
nil-context hits), use exec.CommandContext, and exclude musttag for _test.go
(tests marshal internal persisted structs that rely on default JSON keys).
@l-hellmann l-hellmann self-assigned this May 20, 2026
@l-hellmann l-hellmann requested a review from tikinang May 20, 2026 21:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant