Skip to content

feat(cli): notify once a day when a newer jamf-cli release is available - #309

Merged
neilmartin83 merged 2 commits into
mainfrom
feat/update-notifier
Jul 30, 2026
Merged

feat(cli): notify once a day when a newer jamf-cli release is available#309
neilmartin83 merged 2 commits into
mainfrom
feat/update-notifier

Conversation

@neilmartin83

Copy link
Copy Markdown
Member

Why

jamf-cli ships weekly-ish (v1.23.0 → v1.25.2 in two weeks) across four install channels — Homebrew tap, .pkg, release tarball, go install — and none of them tells a user their build is stale. Admins then hit "no such command" on endpoints a newer build already generates. checkTenantVersion already warns about the mirror case (tenant older than the baked-in spec); this closes the other half.

What it does

Once a day, an interactive release build prints one hint to stderr after the command's own output:

hint: new jamf-cli release: v1.25.2 → v1.26.0 (brew upgrade jamf-cli)
      https://github.com/Jamf-Concepts/jamf-cli/releases/tag/v1.26.0

Design decisions worth reviewing

Module proxy, not the GitHub API. api.github.com caps unauthenticated callers at 60 requests/hour per IP — a budget every admin behind one corporate egress IP shares, so it would fail silently and unpredictably. proxy.golang.org/.../@latest is CDN-backed, uncapped, credential-free, and already canonical for go install. Fallback is the github.com/<repo>/releases/latest 302, also not an API call. Nothing tenant-, profile-, or credential-related leaves the machine: the module path plus the current version in User-Agent is the whole request.

Notify, never self-update. A Homebrew- or pkg-managed binary lives in a path the invoking user often cannot write, and replacing a notarized binary under Homebrew's manifest breaks brew doctor. The notice instead names the upgrade that works for that install (brew upgrade / go install / releases URL), detected by path rather than by shelling out to brew --prefix. Homebrew installs get a 24h grace period so the hint never points at a version the tap hasn't picked up.

One gate, printed from PostRun. updateCheckGate holds the policy as data, so it's testable without a terminal or a network. It stays silent unless: the version is an exact release tag (not dev, a git describe suffix, -dirty, or a prerelease), both stdout and stderr are TTYs, no CI marker is set, the command isn't machine-facing (mcp speaks JSON-RPC on stdio; also completion, agent-context, version, help, __complete*), and neither --quiet nor --no-hints is in play. Printing from PersistentPostRunE means it can't delay or interleave with real output, and says nothing when the command failed.

Cost is bounded. Probe runs in a goroutine with a 2s deadline; results cache for 24h — 1h for failures, cached on purpose so an offline machine stops paying the timeout on every command. Cache is .update-cache.json beside the config file, 0600, temp-file-then-rename: same shape as the tenant version cache.

resolveVersion fallback. go install github.com/Jamf-Concepts/jamf-cli/cmd/jamf-cli@latest gets no -ldflags, so it reported dev — untraceable in bug reports, and silently opted out of this check. Now falls back to debug.ReadBuildInfo.

Opt-outs

--no-update-check · JAMF_CLI_NO_UPDATE_CHECK=1 (value-parsed, so =0 leaves it on) · update-check: false in config.yaml, for admins who deploy jamf-cli by package and don't want a nag users can't act on. Surfaced in config show.

Tests

38 new cases in internal/commands/update_check_test.go plus 7 in cmd/jamf-cli/main_test.go: version-shape classification, comparison ordering, cache TTL/round-trip/permissions/corruption, both fetch sources against httptest (including proxy-path capital escaping, HEAD-only fallback, proxy→GitHub failover, source preference, context cancellation), install-kind detection, notice formatting and every silent case, the full gate veto matrix, CI-marker detection, all three opt-outs, command-skip walking, probe/cache/notify integration, timeout behaviour, and root wiring.

Also verified on a real binary under a pty: notice appears, cache lands, and it goes silent when piped, in CI, under --quiet, with the flag, with either env value, with update-check: false, on version, and when already on the latest release.

make test (28 packages), make lint (0 issues), make verify-generated, make verify-site, go test -race, go fix, make fmt all clean.

Dependency

golang.org/x/mod v0.37.0 promoted from indirect to direct (semver + module.EscapePath). No new modules in the graph.

🤖 Generated with Claude Code

neilmartin83 and others added 2 commits July 29, 2026 18:24
jamf-cli ships weekly-ish across four install channels (Homebrew tap,
.pkg, release tarball, go install) and none of them tells a user their
build is stale, so admins hit "no such command" on endpoints a newer
build already generates. This adds the mirror of the existing tenant
compatibility warning: a once-a-day advisory when the running binary is
behind the latest release.

Latest-version lookup reads the Go module proxy, not the GitHub API:
api.github.com caps unauthenticated callers at 60 requests/hour per IP,
a budget every admin behind one corporate egress IP would share. The
proxy is CDN-backed, uncapped, and needs no credentials; the fallback is
the github.com/<repo>/releases/latest redirect, also not an API call.
Comparison uses golang.org/x/mod/semver (already in the module graph);
compareProVersions is unsuitable — it ignores prerelease ordering.

Notify only, never self-update: a Homebrew- or pkg-managed binary sits
in a path the invoking user often cannot write. The notice instead names
the upgrade that works for that install (brew upgrade / go install /
releases URL), with a 24h grace period for Homebrew so it never points
at a version the tap has not picked up yet.

Every suppression rule lives in updateCheckGate so the policy is
testable without a terminal or a network. It stays silent unless the
version is an exact release tag, both stdout and stderr are TTYs, no CI
marker is set, and the command isn't machine-facing (mcp speaks JSON-RPC
on stdio). Opt out with --no-update-check, JAMF_CLI_NO_UPDATE_CHECK, or
update-check: false in config.yaml.

The probe runs in a goroutine with a 2s deadline and prints in
PersistentPostRunE, so it can neither delay nor interleave with real
output, and stays quiet when the command failed. Results cache for 24h
(1h for failures, so an offline machine stops paying the timeout) in
.update-cache.json beside the config file, 0600, written atomically —
same shape as the tenant version cache.

main.resolveVersion falls back to debug.ReadBuildInfo so `go install`
builds report their module version instead of "dev", which previously
made them untraceable in bug reports and now also opts them into the
check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Captures the three things a future contributor would otherwise
rediscover: why the Go module proxy replaces the GitHub API as the
version source (60 req/hour per IP), why the notifier never self-updates,
and why every suppression rule lives in one gate printed from PostRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@neilmartin83
neilmartin83 merged commit 146dc8c into main Jul 30, 2026
1 check passed
@neilmartin83
neilmartin83 deleted the feat/update-notifier branch July 30, 2026 13:28
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.

2 participants