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): warn when a newer spec-kit release is available (opt-in via `SPECIFY_ENABLE_UPDATE_CHECK=1`; suppressed when `CI` is set or stdout is not a TTY; result cached for 24h) (#1320)

## [0.10.2] - 2026-06-11

### Changed
Expand Down
17 changes: 17 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,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 @@ -77,6 +77,7 @@
self_app as _self_app,
self_check as self_check,
self_upgrade as self_upgrade,
_check_for_updates,
)
from ._agent_config import (
AGENT_CONFIG as AGENT_CONFIG,
Expand Down Expand Up @@ -113,6 +114,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 not in {"version", "self"}
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
175 changes: 175 additions & 0 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
Expand Down Expand Up @@ -1427,3 +1428,177 @@ def self_upgrade(
f"Upgraded specify-cli: {pre_upgrade_display} → {verified_display}",
soft_wrap=True,
)


# ===== 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"))
if not isinstance(data, dict):
return None

checked_at = float(data.get("checked_at", 0))
now = time.time()
if not math.isfinite(checked_at) or checked_at > now:
return None
if now - checked_at > _UPDATE_CHECK_CACHE_TTL_SECONDS:
return None

latest = data.get("latest")
if latest is not None and not isinstance(latest, str):
return None
return data
except Exception:
return None


def _create_cache_dir_no_symlinks(directory: Path) -> bool:
"""Create *directory* (with missing parents) without following symlinks.

``mkdir(parents=True)`` silently traverses symlinked ancestors, so a planted
symlink anywhere on the way could redirect the cache write to an arbitrary
location. Instead we walk one component at a time: the deepest pre-existing
ancestor must be a real directory (not a symlink), and every component we
create is re-checked after ``mkdir``. Everything at/above the first existing
directory is trusted, mirroring the project's other safe-write helpers.

Returns False (and writes nothing) when any managed component is a symlink.
"""
missing: list[Path] = []
current = directory
while not current.exists():
missing.append(current)
parent = current.parent
if parent == current: # reached the filesystem root
break
current = parent
# Deepest pre-existing ancestor: refuse if it's a symlink or not a directory.
if current.is_symlink() or not current.is_dir():
return False
# Create the missing tail top-down, one component at a time, re-checking each
# so a concurrently-planted symlink can't slip in under a TOCTOU race.
for component in reversed(missing):
try:
component.mkdir()
except FileExistsError:
pass # created concurrently; validated immediately below
if component.is_symlink() or not component.is_dir():
return False
return True


def _write_update_check_cache(path: Path, latest: str | None) -> None:
try:
# Refuse to follow symlinks anywhere on the way to the cache file: an
# attacker (or misconfigured XDG_CACHE_HOME) could otherwise have us
# overwrite an arbitrary file the current user can write.
if not _create_cache_dir_no_symlinks(path.parent):
return
if path.is_symlink():
return

payload = json.dumps({"checked_at": time.time(), "latest": latest}).encode("utf-8")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(str(path), flags, 0o600)
try:
os.write(fd, payload)
finally:
os.close(fd)
Comment on lines +1525 to +1529
except Exception:
Comment on lines +1513 to +1530

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 8be289d: cache writes now refuse symlinked paths. The destination file and its parent directory are checked with is_symlink(), and the actual write goes through os.open(..., O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW, 0o600) so the kernel refuses to follow a symlink even if one is swapped in between the check and the open. Covered by test_write_refuses_symlinked_cache_file and test_write_refuses_symlinked_parent_dir.

# 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 thread
ATelbay marked this conversation as resolved.
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