Imported from schmitthub/clawker (
internal/update/AGENTS.md). Install upstream withnpx skills add schmitthub/clawker --skill update. Copyright stays with the author.
Update Package
Update checker for clawker releases. Queries the GitHub releases API, compares
the latest tag against the running version, reads the freshness gate from CLI
state (internal/state), and persists the check there itself.
Dependencies: stdlib + net/http + internal/state + github.com/Masterminds/semver/v3.
The package owns ALL semver work — the caller passes raw version strings and imports no semver
The freshness gate (shouldCheckForUpdate) is pure (a timestamp in, no I/O).
The package is split so the network hop and the state access can happen at
different times: GetLatestReleaseInfo does the GitHub fetch and nothing else,
while CheckForUpdate owns the semver parse, the newer/not-newer comparison
(!cv.LessThan(lv)), and the state read + write over an already-fetched
release. The CLI fetches before running the user's command and decides after
(see internal/clawkercmd/AGENTS.md).
The caller passes the current version string (no dependency on internal/build).
RecordUpdateCheck is a field merge that writes only the update-check fields, so
it never clobbers the changelog cursor that internal/changelog writes to the
same state file.
Exported Type
// ReleaseInfo describes a strictly newer release than the running version. A
// non-nil *ReleaseInfo MEANS "a newer release exists" — CheckForUpdate returns
// one only when LatestVersion is a newer semver than CurrentVersion, so the
// caller never re-checks a flag.
type ReleaseInfo struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
}
There is no IsNewer field: presence of a non-nil *ReleaseInfo is itself the
"newer release exists" signal.
Exported Functions
// GithubRelease is the partial "latest release" payload — the fetch half's
// output and CheckForUpdate's input.
type GithubRelease struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
func GetLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*GithubRelease, error)
func CheckForUpdate(release *GithubRelease, st state.StateStore, currentVersion string) (*ReleaseInfo, error)
GetLatestReleaseInfo is network only — no state, no decisions. The caller
supplies the *http.Client (the Factory's HttpClient noun in production; an
internal/httpmock stub in tests) and the context that bounds the request.
CheckForUpdate return contract:
| Return | Meaning |
|---|---|
(nil, nil) |
up-to-date, TTL-fresh, or the release is not newer than currentVersion |
(*ReleaseInfo, nil) |
a strictly newer release is available |
(nil, error) |
a version string failed to parse, or the persist failed |
CheckForUpdate reads st.CheckedAt() for the freshness gate. Because the
fetch already happened by then, the gate now throttles the notification and the
state write rather than the network hop — a caller that fetches unconditionally
will reach GitHub on every run. A nil st is a programming error (the caller
wires state via the factory) and returns an error.
Persist on every parsed release (not on the newer/not-newer comparison)
st.RecordUpdateCheck(time.Now(), latestVersion) fires on every release that
parses — keyed on the fetch having succeeded, not on whether a newer release
was found — and runs before the newer/not-newer decision. This is
load-bearing: if persistence only happened when a newer release was found,
checked_at would never advance on the common not-newer path and the TTL gate
would never throttle. A persistence failure surfaces as (nil, error).
Env/CI opt-out is the caller's responsibility
shouldCheckForUpdate is the TTL freshness gate only — it does not read any
env var. Opt-out suppression (an env-var kill switch, CI detection) lives in the
caller (internal/clawkercmd/cmd.go), which decides whether to call
CheckForUpdate at all. Defense-in-depth note: a future second caller that
bypasses that gate would still reach the GitHub API, because the opt-out is not
enforced inside this package.
Freshness Gate
shouldCheckForUpdate(lastCheckedAt time.Time) bool returns false only when a
check ran recently:
| Condition | Rationale |
|---|---|
lastCheckedAt within cacheTTL (24h, and non-zero, non-future) |
Rate limiting |
A zero lastCheckedAt means "never checked" — the TTL gate never suppresses. A
future timestamp (clock skew, later corrected) is treated as stale, not fresh
(elapsed >= 0 guard), so it does not spuriously suppress checks.
Dev-build handling lives at the parse boundary, not in a gate.
CheckForUpdate parses currentVersion with semver.NewVersion before
anything else; a non-release build whose version is not parseable semver
("DEV", "nightly") fails there and returns (nil, error) without reading or
writing state. The release tag from GitHub is parsed the same way
(semver.NewVersion(release.TagName)), and the newer/not-newer decision is a
plain !cv.LessThan(lv) on the two parsed *semver.Version values — no
constraint, no string round-trip. There is no separate isNewer helper and no
caller-side dev gate: internal/clawkercmd/cmd.go imports no semver, passes the raw
buildVersion string, and lets this package own every parse.
cacheTTL and shouldCheckForUpdate are unexported — the package surface is
just CheckForUpdate + ReleaseInfo.
CheckForUpdate Flow
- Parse
currentVersionwithsemver.NewVersion(v-tolerant); unparseable (e.g."DEV") →(nil, error), before any fetch - Reject a nil
st(error); readst.CheckedAt();shouldCheckForUpdate→ return(nil, nil)if TTL-fresh - HTTP GET
https://api.github.com/repos/{owner}/{repo}/releases/latestvia the supplied client (timeout from the client, context-aware) - Read
html_url+ parsetag_namewithsemver.NewVersion(unparseable tag →(nil, error)) - Persist
st.RecordUpdateCheck(now, lv.String())before the newer/not-newer decision (stis guaranteed non-nil here — a nilstwas rejected in step 2) !cv.LessThan(lv); not newer → return(nil, nil)- Return
(*ReleaseInfo, nil). On any fetch/parse error:(nil, error)
The unexported getLatestReleaseInfo(ctx, client, repo) GETs and decodes the
latest release; CheckForUpdate builds the GitHub URL from repo internally.
There is no URL seam — tests inject an internal/httpmock client (its
transport is the seam) plus a state/mocks stub where only call counts matter.
Integration Point
Wired into internal/clawkercmd/cmd.go:Main() (gh CLI pattern):
Mainowns the env/CI opt-out decision (whether to launch the check at all)context.WithCancelcreates a cancellable context for the HTTP request- A buffered(1) channel carries the
*update.ReleaseInfo; the goroutine sends exactly once and recovers from panics - The goroutine calls the
checkForUpdate(ctx, f, buildVersion, consts.GitHubRepo)helper, which resolves thef.HttpClient()+f.CLIState()Factory nouns and callsupdate.CheckForUpdate(ctx, client, st, buildVersion, consts.GitHubRepo)— the package persists the check itself - Blocking read after the command completes
- The notification prints whenever the result is non-nil (a newer release
exists); TTY/CI/opt-out suppression is the up-front
notificationsSuppressedgate's job, not the renderer's - Errors logged via
logger.Debug().Err(err)(always to file log)
State file (owned by internal/state): config.StateDir()/update-state.yaml
(consts.CLIStateFile).
Testing
update_test.go uses an internal/httpmock registry to stub the GitHub API
(releaseStub registers the /releases/latest responder; reg.Client() is
injected into CheckForUpdate). Tests cover:
TTL suppression (fresh/stale/zero-time, including the future-timestamp-is-stale
guard), newer (→ *ReleaseInfo) vs same/older (→ nil), API errors, malformed
JSON, empty tag_name, context cancellation, and v-prefix handling. The
newer/same/older comparison is exercised through CheckForUpdate (newer →
*ReleaseInfo, same/older → nil) rather than a standalone comparator test. A
regression test
(TestCheckForUpdate_NotNewerAdvancesCheckedAt) proves a not-newer fetch still
records the check by asserting RecordUpdateCheckCalls() on a
internal/state/mocks stub (NewBlankState()) — the persist-on-fetch-success
contract. Tests drive CheckForUpdate (and the unexported GetLatestReleaseInfo
for the decode/cancellation cases) through an internal/httpmock client, so they
exercise the real gate→fetch→persist→assemble path with no live network. State
persistence/non-clobber internals are covered in internal/state.