Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

<!-- 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)

## [0.7.3] - 2026-04-17

### Changed
Expand Down
17 changes: 17 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ After initialization, you should see the following commands available in your AI

The `.specify/scripts` directory will contain both `.sh` and `.ps1` scripts.

### 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 2-second fetch timeout) while contacting GitHub.

## Troubleshooting

### Enterprise / Air-Gapped Installation
Expand Down
161 changes: 161 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,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 run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> Optional[str]:
"""Run a shell command and optionally capture output."""
Expand Down Expand Up @@ -1694,6 +1704,157 @@ def get_speckit_version() -> str:
return "unknown"


# ===== Update check (addresses #1320) =====
#
# Opt-in only (set SPECIFY_ENABLE_UPDATE_CHECK=1). Air-gapped / network-constrained
# environments never reach GitHub, so the check is off by default. When enabled,
# it is cached once per 24h in the platform user-cache dir and triggered from the
# top-level callback. Best-effort: every failure path swallows the exception so
# the check never fails the command, though cache misses may add a small startup
# delay (bounded by the fetch timeout) while contacting GitHub.

_UPDATE_CHECK_URL = "https://api.github.com/repos/github/spec-kit/releases/latest"
_UPDATE_CHECK_CACHE_TTL_SECONDS = 24 * 60 * 60
_UPDATE_CHECK_TIMEOUT_SECONDS = 2.0


def _parse_version_tuple(version: str | None) -> tuple[int, ...] | None:
"""Parse `v0.6.2` / `0.6.2` / `0.6.2.dev0` → tuple of ints. Returns None if unparseable."""
if not isinstance(version, str) or not version:
return None
s = version.strip().lstrip("vV")
# Drop PEP 440 pre/post/dev/local segments; we only compare release numbers.
for sep in ("-", "+", "a", "b", "rc", ".dev", ".post"):
idx = s.find(sep)
if idx != -1:
s = s[:idx]
parts: list[int] = []
for piece in s.split("."):
if not piece.isdigit():
return None
parts.append(int(piece))
return tuple(parts) if parts else None


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:
import time
if not path.exists():
return None
data = json.loads(path.read_text(encoding="utf-8"))
checked_at = float(data.get("checked_at", 0))
Comment thread
mnriem marked this conversation as resolved.
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:
try:
import time
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 _fetch_latest_version() -> str | None:
"""Query GitHub for the latest release tag. Returns None on any failure."""
try:
import urllib.request
req = urllib.request.Request(
_UPDATE_CHECK_URL,
headers={"Accept": "application/vnd.github+json", "User-Agent": "specify-cli"},
)
with urllib.request.urlopen(req, timeout=_UPDATE_CHECK_TIMEOUT_SECONDS) as resp:
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
return tag if isinstance(tag, str) and tag else None
except Exception:
return None


def _should_skip_update_check() -> bool:
# Opt-in only: skip unless the user has explicitly enabled the check.
# Air-gapped / network-constrained environments cannot reach GitHub, so a
# default-on network call is a non-starter; keeping this off by default
# also means users never pay the fetch latency unless they asked for it.
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
Comment on lines +1790 to +1797
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says the update warning is shown by default and suppressed via SPECIFY_SKIP_UPDATE_CHECK=1, but the implementation is opt-in via SPECIFY_ENABLE_UPDATE_CHECK (and there is no SPECIFY_SKIP_UPDATE_CHECK handling). Please reconcile by either updating the PR description/spec or adjusting the skip logic/env-var naming so the documented behavior matches the shipped behavior.

Copilot uses AI. Check for mistakes.
# caller isn't a TTY (piped output, redirected logs, etc.) so we don't
# dirty machine-readable output with a human-facing warning.
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 blocked.
"""
if _should_skip_update_check():
return
try:
current_str = get_speckit_version()
current = _parse_version_tuple(current_str)
if current is None:
return

cache_path = _update_check_cache_path()
latest_str: str | None = None
if cache_path is not None:
cached = _read_update_check_cache(cache_path)
if cached:
latest_str = cached.get("latest")

if latest_str is None:
latest_str = _fetch_latest_version()
if latest_str and cache_path is not None:
_write_update_check_cache(cache_path, latest_str)

latest = _parse_version_tuple(latest_str) if latest_str else None
if latest is None or latest <= current:
return

current_display = current_str.lstrip("vV")
latest_display = latest_str.lstrip("vV")
console.print(
f"[yellow]⚠ A new spec-kit version is available: "
f"v{latest_display} (you have v{current_display})[/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


# ===== Integration Commands =====

integration_app = typer.Typer(
Expand Down
Loading