diff --git a/CHANGELOG.md b/CHANGELOG.md index a040aca1a7..b7a53e1be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ +## [Unreleased] + +### Added + +- feat(cli): opt-in launch warning when a newer spec-kit release is available; enable with `SPECIFY_ENABLE_UPDATE_CHECK=1` (or `true`/`yes`/`on`), cached for 24h, and suppressed in non-interactive shells and when `CI` is set (#1320) +- fix(cli): cache update-check failures so transient outages don't trigger a network call on every CLI invocation (#1320) +- refactor(cli): move update-check helpers into `_version.py` and reuse the existing `_get_installed_version` / `_fetch_latest_release_tag` / `_is_newer` primitives from #2550 (#1320) + ## [0.8.13] - 2026-05-21 ### Changed diff --git a/docs/installation.md b/docs/installation.md index b3c25ce469..be295d5eb5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -99,6 +99,23 @@ Scripts are installed into a variant subdirectory matching the chosen script typ - `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS) - `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows) +### Update Notifications + +`specify` can check once per 24 hours whether a newer release is available on GitHub and print an upgrade hint. This is **opt-in**: the check is off by default because air-gapped and network-constrained environments cannot reach GitHub. + +To enable it, set: + +```bash +export SPECIFY_ENABLE_UPDATE_CHECK=1 # or true / yes / on +``` + +Even when enabled, the check stays silent when: + +- stdout is not a TTY (piped output, redirected to a file, etc.) +- the `CI` environment variable is set + +Network failures and rate-limit responses are swallowed — the check never fails the command you ran, though a cache miss may add a small startup delay (bounded by a 5-second fetch timeout) while contacting GitHub. Failures are also cached for the same 24h window, so a transient outage or block won't cause the CLI to retry on every invocation. + ## Troubleshooting ### Enterprise / Air-Gapped Installation diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index c0bdbaabe3..e875574055 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -97,6 +97,7 @@ self_app as _self_app, self_check as self_check, self_upgrade as self_upgrade, + _check_for_updates, ) def _build_agent_config() -> dict[str, dict[str, Any]]: @@ -200,6 +201,16 @@ def callback( show_banner() console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) console.print() + # Addresses #1320: nudge users running outdated CLIs. The `version` subcommand + # already surfaces the version, so skip there to avoid double-printing; also + # skip help invocations. Runs on bare `specify` too so the banner launch + # benefits from the nudge when the user has opted in. + if ( + ctx.invoked_subcommand != "version" + and "--help" not in sys.argv + and "-h" not in sys.argv + ): + _check_for_updates() def _refresh_shared_templates( project_path: Path, diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 0a52ac7e80..7152856df2 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -10,7 +10,11 @@ from __future__ import annotations import json +import os +import sys +import time import urllib.error +from pathlib import Path import typer from packaging.version import InvalidVersion, Version @@ -171,3 +175,125 @@ def self_upgrade() -> None: console.print("specify self upgrade is not implemented yet.") console.print("Run 'specify self check' to see whether a newer release is available.") console.print("Actual self-upgrade is planned as follow-up work.") + + +# ===== Opt-in startup update check (addresses #1320) ===== +# +# Silent companion to `specify self check`: when SPECIFY_ENABLE_UPDATE_CHECK=1 +# is set in an interactive non-CI shell, the top-level Typer callback prints +# upgrade guidance if a newer release is available. Result is cached for 24h in +# the platform user-cache dir; cache misses are written even on fetch failure +# (`latest=null`) so a transient outage doesn't trigger a network call on every +# CLI invocation. Best-effort: every error path swallows the exception so the +# helper never fails the command the user actually invoked, though cache misses +# may add a bounded startup delay while contacting GitHub. + +_UPDATE_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60 + + +def _update_check_cache_path() -> Path | None: + try: + from platformdirs import user_cache_dir + return Path(user_cache_dir("specify-cli")) / "version_check.json" + except Exception: + return None + + +def _read_update_check_cache(path: Path) -> dict | None: + try: + if not path.exists(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + checked_at = float(data.get("checked_at", 0)) + latest = data.get("latest") + if latest is not None and not isinstance(latest, str): + return None + if time.time() - checked_at > _UPDATE_CHECK_CACHE_TTL_SECONDS: + return None + return data + except Exception: + return None + + +def _write_update_check_cache(path: Path, latest: str | None) -> None: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"checked_at": time.time(), "latest": latest}), + encoding="utf-8", + ) + except Exception: + # Cache write failures are non-fatal. + pass + + +def _should_skip_update_check() -> bool: + # Opt-in only: air-gapped / network-constrained environments cannot reach + # GitHub, so the check is off by default. + if os.environ.get("SPECIFY_ENABLE_UPDATE_CHECK", "").strip().lower() not in ("1", "true", "yes", "on"): + return True + # Belt-and-suspenders: even when opted in, suppress in CI and when the + # caller isn't a TTY so we don't dirty machine-readable output. + if os.environ.get("CI"): + return True + try: + if not sys.stdout.isatty(): + return True + except Exception: + return True + return False + + +def _check_for_updates() -> None: + """Print upgrade guidance when a newer spec-kit release is available. + + Fully best-effort — any error (offline, rate-limited, parse failure) is + swallowed so the command the user actually invoked is never failed. + """ + if _should_skip_update_check(): + return + try: + current = _get_installed_version() + if current == "unknown": + return + + cache_path = _update_check_cache_path() + cached = _read_update_check_cache(cache_path) if cache_path is not None else None + if cached is not None: + # Fresh cache hit — may be a positive (`latest=v…`) or + # negative (`latest=null`) entry; either way, no fetch. + latest_tag = cached.get("latest") + else: + try: + latest_tag, _reason = _fetch_latest_release_tag() + except Exception: + if cache_path is not None: + # Cache malformed/unexpected fetch failures too, so they + # don't trigger a network call on every CLI invocation. + _write_update_check_cache(cache_path, None) + return + if cache_path is not None: + # Cache the attempt even on failure so transient outages + # don't trigger a network call on every CLI invocation. + _write_update_check_cache(cache_path, latest_tag) + + if not latest_tag: + return + latest_display = _normalize_tag(latest_tag) + if not _is_newer(latest_display, current): + return + + console.print( + f"[yellow]⚠ A new spec-kit version is available: " + f"v{latest_display} (you have v{current})[/yellow]" + ) + console.print( + f"[dim] Upgrade: uv tool install specify-cli --force " + f"--from git+https://github.com/github/spec-kit.git@{latest_tag}[/dim]" + ) + console.print( + "[dim] (unset SPECIFY_ENABLE_UPDATE_CHECK to disable this check)[/dim]" + ) + except Exception: + # Update check must never surface an error to the user. + return diff --git a/tests/test_update_check.py b/tests/test_update_check.py new file mode 100644 index 0000000000..1d9e282af1 --- /dev/null +++ b/tests/test_update_check.py @@ -0,0 +1,286 @@ +"""Tests for the update-check helper in specify_cli._version. + +Covers issue https://github.com/github/spec-kit/issues/1320 — the CLI should +nudge users who are running outdated releases toward an upgrade, without +failing any command when offline or rate-limited. +""" + +import json +import time +from io import StringIO + +from specify_cli._version import ( + _check_for_updates, + _read_update_check_cache, + _write_update_check_cache, +) + + + +class TestCache: + def test_fresh_cache_is_returned(self, tmp_path): + cache_file = tmp_path / "version_check.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": "v0.7.0"})) + data = _read_update_check_cache(cache_file) + assert data is not None + assert data["latest"] == "v0.7.0" + + def test_stale_cache_is_ignored(self, tmp_path): + cache_file = tmp_path / "version_check.json" + very_old = time.time() - (48 * 60 * 60) + cache_file.write_text(json.dumps({"checked_at": very_old, "latest": "v0.5.0"})) + assert _read_update_check_cache(cache_file) is None + + def test_missing_cache_returns_none(self, tmp_path): + assert _read_update_check_cache(tmp_path / "missing.json") is None + + def test_corrupt_cache_returns_none(self, tmp_path): + cache_file = tmp_path / "version_check.json" + cache_file.write_text("{not json") + assert _read_update_check_cache(cache_file) is None + + def test_invalid_latest_cache_type_returns_none(self, tmp_path): + cache_file = tmp_path / "version_check.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": ["v0.7.0"]})) + assert _read_update_check_cache(cache_file) is None + + def test_write_round_trips(self, tmp_path): + cache_file = tmp_path / "nested" / "version_check.json" + _write_update_check_cache(cache_file, "v0.9.9") + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["latest"] == "v0.9.9" + assert float(data["checked_at"]) <= time.time() + + def test_write_round_trips_negative_entry(self, tmp_path): + cache_file = tmp_path / "nested" / "version_check.json" + _write_update_check_cache(cache_file, None) + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["latest"] is None + assert float(data["checked_at"]) <= time.time() + + + +class TestCheckForUpdates: + """End-to-end-ish checks on `_check_for_updates` with skip conditions patched off.""" + + def _run_and_capture(self, monkeypatch) -> str: + """Force the skip-guard off so the helper runs, then capture console output.""" + # Guard returns False → helper proceeds. + monkeypatch.setattr("specify_cli._version._should_skip_update_check", lambda: False) + buf = StringIO() + import specify_cli._version + from rich.console import Console + captured = Console(file=buf, force_terminal=False, width=200) + monkeypatch.setattr(specify_cli._version, "console", captured) + _check_for_updates() + return buf.getvalue() + + def test_prints_warning_when_newer_release_available(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", lambda: ("v0.7.0", None)) + + out = self._run_and_capture(monkeypatch) + + assert "new spec-kit version is available" in out + assert "v0.7.0" in out + assert "v0.6.2" in out + assert "uv tool install specify-cli" in out + + def test_upgrade_command_uses_exact_release_tag(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", lambda: ("0.7.0", None)) + + out = self._run_and_capture(monkeypatch) + + assert "git+https://github.com/github/spec-kit.git@0.7.0" in out + assert "git+https://github.com/github/spec-kit.git@v0.7.0" not in out + + def test_no_output_when_up_to_date(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.7.0") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", lambda: ("v0.7.0", None)) + + out = self._run_and_capture(monkeypatch) + + assert out == "" + + def test_uses_cache_when_fresh(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": "v0.7.0"})) + + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._version._update_check_cache_path", lambda: cache_file) + + call_counter = {"n": 0} + + def _should_not_be_called() -> tuple[str | None, str | None]: + call_counter["n"] += 1 + return (None, None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _should_not_be_called) + + out = self._run_and_capture(monkeypatch) + + assert call_counter["n"] == 0 + assert "v0.7.0" in out + + def test_network_failure_is_silent(self, monkeypatch, tmp_path): + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", lambda: (None, "offline or timeout")) + + out = self._run_and_capture(monkeypatch) + + assert out == "" + + def test_network_failure_writes_negative_cache(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._version._update_check_cache_path", lambda: cache_file) + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", lambda: (None, "offline or timeout")) + + self._run_and_capture(monkeypatch) + + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["latest"] is None + assert time.time() - float(data["checked_at"]) < 5 + + def test_fetch_exception_writes_negative_cache(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._version._update_check_cache_path", lambda: cache_file) + + def _fetch_raises() -> tuple[str | None, str | None]: + raise ValueError("GitHub API response missing valid tag_name") + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _fetch_raises) + + out = self._run_and_capture(monkeypatch) + + assert out == "" + assert cache_file.exists() + data = json.loads(cache_file.read_text()) + assert data["latest"] is None + assert time.time() - float(data["checked_at"]) < 5 + + def test_negative_cache_skips_fetch_within_ttl(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": None})) + + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._version._update_check_cache_path", lambda: cache_file) + + call_counter = {"n": 0} + + def _should_not_be_called() -> tuple[str | None, str | None]: + call_counter["n"] += 1 + return (None, None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _should_not_be_called) + + out = self._run_and_capture(monkeypatch) + + assert call_counter["n"] == 0 + assert out == "" + + def test_invalid_latest_cache_type_is_treated_as_miss(self, monkeypatch, tmp_path): + cache_file = tmp_path / "vc.json" + cache_file.write_text(json.dumps({"checked_at": time.time(), "latest": ["v0.7.0"]})) + + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.6.2") + monkeypatch.setattr("specify_cli._version._update_check_cache_path", lambda: cache_file) + + call_counter = {"n": 0} + + def _fetch() -> tuple[str | None, str | None]: + call_counter["n"] += 1 + return ("v0.7.0", None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _fetch) + + out = self._run_and_capture(monkeypatch) + + assert call_counter["n"] == 1 + assert "v0.7.0" in out + + def test_opt_in_default_off_short_circuits(self, monkeypatch, tmp_path): + """Without SPECIFY_ENABLE_UPDATE_CHECK the helper must not hit the network.""" + monkeypatch.delenv("SPECIFY_ENABLE_UPDATE_CHECK", raising=False) + + fetched = {"called": False} + + def _fetch(): + fetched["called"] = True + return ("v99.0.0", None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _fetch) + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.0.1") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + + _check_for_updates() + + assert fetched["called"] is False + + def test_opt_in_env_var_allows_check(self, monkeypatch, tmp_path): + """With SPECIFY_ENABLE_UPDATE_CHECK=1 and a TTY, the helper proceeds.""" + monkeypatch.setenv("SPECIFY_ENABLE_UPDATE_CHECK", "1") + monkeypatch.delenv("CI", raising=False) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + fetched = {"called": False} + + def _fetch(): + fetched["called"] = True + return ("v99.0.0", None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _fetch) + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.0.1") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + + _check_for_updates() + + assert fetched["called"] is True + + def test_ci_suppresses_even_when_opted_in(self, monkeypatch, tmp_path): + """Belt-and-suspenders: CI=1 wins over the opt-in flag. + + Pin isatty()=True so this test fails if the CI guard is removed — + otherwise pytest's stdout capture makes isatty False and the TTY + guard alone would suppress the fetch, masking a regression. + """ + monkeypatch.setenv("SPECIFY_ENABLE_UPDATE_CHECK", "1") + monkeypatch.setenv("CI", "1") + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + + fetched = {"called": False} + + def _fetch(): + fetched["called"] = True + return ("v99.0.0", None) + + monkeypatch.setattr("specify_cli._version._fetch_latest_release_tag", _fetch) + monkeypatch.setattr("specify_cli._version._get_installed_version", lambda: "0.0.1") + monkeypatch.setattr( + "specify_cli._version._update_check_cache_path", lambda: tmp_path / "vc.json" + ) + + _check_for_updates() + + assert fetched["called"] is False