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
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 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
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
126 changes: 126 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,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:
Comment on lines +265 to +266
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
Loading