Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

<!-- insert new changelog below this comment -->

## [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 `CI=1` (#1320)
Comment thread
mnriem marked this conversation as resolved.
- 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.11] - 2026-05-15

### Changed
Expand Down
17 changes: 17 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
mnriem marked this conversation as resolved.
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
Expand Down
11 changes: 11 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]:
Expand Down Expand Up @@ -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()
Comment thread
mnriem marked this conversation as resolved.

def _refresh_shared_templates(
project_path: Path,
Expand Down
123 changes: 123 additions & 0 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,3 +175,122 @@ 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 a
# one-line upgrade hint if a newer release is available. Result is cached for
# 24h in the platform user-cache dir; cache misses are written even on fetch
Comment on lines +183 to +185
# 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))
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 a one-line upgrade hint 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:
Comment on lines +262 to +263
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@v{latest_display}[/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
Loading