module Build::UpdateCheck

Overview

Once-per-day GitHub release check. Fully silent on any failure. Records every attempt (success or failure) so a bad network cannot cause every invocation to re-fetch. Disable with BUILD_NO_UPDATE_CHECK=1.

State is stored as an extended attribute on the user's ~/.netrc:

user.io.build.update-check = "|<version or 'none'>"

This keeps the state cost-free (no extra dotfile, no separate lockfile) and — critically — never touches the netrc's contents, so nothing we do here can corrupt the user's auth tokens. Individual setxattr/getxattr calls are atomic in the kernel, so no locking is needed either.

Fallback strategy: if we can't persist the state (Windows, or a filesystem like FAT/exFAT that returns ENOTSUP from setxattr), the check is skipped entirely rather than run without back-off. Without a working persistent cache we would otherwise hit GitHub on every invocation, which is exactly the "hammering when offline" behavior we're trying to avoid. Users on those platforms simply don't get update notices — that's the correct trade.

Startup latency is bounded by TOTAL_BUDGET. HTTP::Client's connect and read timeouts don't cover the blocking getaddrinfo DNS lookup, so a captive portal or dead resolver could otherwise hang startup for the OS DNS timeout (many seconds). We work around this by running the fetch in a fiber and racing it against select's timeout, and by pre-recording a "none" result before the fetch so back-off is guaranteed even if the process is killed mid-hang.

Defined in:

update_check.cr

Constant Summary

CACHE_TTL = 24.hours
DISABLE_VAR = "BUILD_NO_UPDATE_CHECK"
GITHUB_REPO = "buildio/cli"
IO_TIMEOUT = 500.milliseconds

Backup timeouts on the TCP connect and each socket read once DNS is done.

NO_VERSION = "none"
RELEASE_URL = "https://api.github.com/repos/#{GITHUB_REPO}/releases/latest"
SKIP_ARGV = {"_complete", "--version", "-V", "--help", "-h"}

Argv tokens that indicate a fast/non-interactive path where the check would add user-visible latency for no benefit (shell completion) or where the user is asking a specific quick question (--version / --help).

TOTAL_BUDGET = 700.milliseconds

Wall-clock cap on how long we'll block the CLI waiting for the fetch. If exceeded the fetch fiber keeps running in the background — it may still populate the cache before the process exits, which surfaces the notice on the next invocation.

XATTR_NAME = "user.io.build.update-check"

user.* namespace is required by Linux for non-root xattr writes and is accepted by macOS without special handling.

Class Method Summary

Class Method Detail

def self.check!(current : String, io : IO = STDERR) : Nil #

[View source]
def self.disabled? : Bool #

[View source]
def self.fast_path? : Bool #

Skip on shell completion and on --version / --help, which are the quick-answer paths where an extra HTTP round-trip is most annoying.


[View source]
def self.fetch : String | Nil #

[View source]
def self.interactive?(io : IO) : Bool #

An interactive TTY is a prerequisite: notices to a redirected STDERR would be invisible, and non-TTY contexts (CI, subshells running completion) shouldn't pay the network latency.


[View source]
def self.netrc_path : String #

[View source]
def self.newer?(latest : String, current : String) : Bool #

[View source]
def self.read_entry : NamedTuple(checked_at: Time, latest_version: String | Nil) | Nil #

[View source]
def self.read_xattr(path : String) : String | Nil #

Read the xattr as a UTF-8 string, or nil if absent / unsupported.


[View source]
def self.record(latest : String | Nil) : Bool #

Persists a new attempt as an xattr on the netrc file. Nil is stored as "none" to distinguish "we tried and failed" from "we never tried". Returns true iff the xattr was written; callers use false to mean "we have no working back-off cache — skip the network entirely".


[View source]
def self.refresh : String | Nil #

Refresh the cached "latest" version with a strict wall-clock budget. Pre-records "none" so a hang or DNS block that outlives startup still counts toward back-off. The fetch fiber records its own result so that if it eventually beats the process exit (but not our budget), the next invocation still gets the notice.

If the pre-record fails (xattrs unsupported), skip the network fetch entirely: without a working back-off cache we'd hit GitHub on every invocation, which is worse than not checking at all.


[View source]
def self.write_xattr(path : String, value : String) : Bool #

Returns true iff the syscall succeeded. False signals to callers that this platform/filesystem can't persist state, so the check should be skipped rather than repeated without back-off.


[View source]