feat(cli): notify once a day when a newer jamf-cli release is available - #309
Merged
Conversation
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
enabled auto-merge
July 29, 2026 17:32
grahampugh
approved these changes
Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.checkTenantVersionalready 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:
Design decisions worth reviewing
Module proxy, not the GitHub API.
api.github.comcaps 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/.../@latestis CDN-backed, uncapped, credential-free, and already canonical forgo install. Fallback is thegithub.com/<repo>/releases/latest302, also not an API call. Nothing tenant-, profile-, or credential-related leaves the machine: the module path plus the current version inUser-Agentis 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 tobrew --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.
updateCheckGateholds 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 (notdev, agit describesuffix,-dirty, or a prerelease), both stdout and stderr are TTYs, no CI marker is set, the command isn't machine-facing (mcpspeaks JSON-RPC on stdio; alsocompletion,agent-context,version,help,__complete*), and neither--quietnor--no-hintsis in play. Printing fromPersistentPostRunEmeans 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.jsonbeside the config file, 0600, temp-file-then-rename: same shape as the tenant version cache.resolveVersionfallback.go install github.com/Jamf-Concepts/jamf-cli/cmd/jamf-cli@latestgets no-ldflags, so it reporteddev— untraceable in bug reports, and silently opted out of this check. Now falls back todebug.ReadBuildInfo.Opt-outs
--no-update-check·JAMF_CLI_NO_UPDATE_CHECK=1(value-parsed, so=0leaves it on) ·update-check: falseinconfig.yaml, for admins who deploy jamf-cli by package and don't want a nag users can't act on. Surfaced inconfig show.Tests
38 new cases in
internal/commands/update_check_test.goplus 7 incmd/jamf-cli/main_test.go: version-shape classification, comparison ordering, cache TTL/round-trip/permissions/corruption, both fetch sources againsthttptest(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, withupdate-check: false, onversion, 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 fmtall clean.Dependency
golang.org/x/modv0.37.0 promoted from indirect to direct (semver+module.EscapePath). No new modules in the graph.🤖 Generated with Claude Code