feat(cli): warn when a newer spec-kit release is available (#1320)#2212
feat(cli): warn when a newer spec-kit release is available (#1320)#2212ATelbay wants to merge 9 commits into
Conversation
Print a one-line upgrade hint on every launch when the installed CLI is older than the latest GitHub release. Cached for 24h and suppressed when SPECIFY_SKIP_UPDATE_CHECK is set, CI=1 is set, or stdout is not a TTY. Any network / parse failure is swallowed — the command the user invoked is never blocked. Closes github#1320.
There was a problem hiding this comment.
Pull request overview
Adds a best-effort “new version available” notice to the specify CLI startup flow to help users discover they need to upgrade when running an outdated specify-cli release.
Changes:
- Implement a cached (24h) GitHub release check in
specify_cli.__init__and print an upgrade hint when a newer tag is available. - Add a new
tests/test_update_check.pysuite covering version parsing, cache behavior, network/JSON failure swallowing, and end-to-end output behavior. - Document update notifications in
docs/installation.mdand add an Unreleased changelog entry.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds update-check helpers and invokes the check from the Typer callback. |
tests/test_update_check.py |
New tests validating parsing/caching/network handling and printed warning behavior. |
docs/installation.md |
Documents update-check behavior and opt-out/skip conditions. |
CHANGELOG.md |
Adds an Unreleased entry describing the new update warning behavior. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:1648
_write_update_check_cacheusespath.write_text(...)without an explicit encoding. For consistency with other file writes/reads in this module and to avoid platform default-encoding issues, specifyencoding="utf-8"here as well.
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"checked_at": time.time(), "latest": latest}))
except Exception:
- Files reviewed: 4/4 changed files
- Comments generated: 5
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback and be aware that this MUST be an opt-in and NOT an opt-out as air-gapped / network-constrained environments will not have access to GitHub perse
Addresses CHANGES_REQUESTED on github#2212. The update check now only runs when SPECIFY_ENABLE_UPDATE_CHECK=1 (or true/yes/on) is set, so air-gapped and network-constrained environments never attempt to reach GitHub by default. Also addresses the Copilot review findings: - Widen `_parse_version_tuple(version: str | None)` signature and guard with `isinstance` (matches what the tests were already passing). - Use explicit `encoding="utf-8"` for the update-check cache read and write, consistent with the rest of the module. - Reword the "never blocks" claim in the module comment and in docs/installation.md to "never fails the command", and note the possible small startup delay on cache miss. - Include the `None` `invoked_subcommand` case (bare `specify` launch) so the check runs alongside the banner when opted in. Tests: - Replace the opt-out short-circuit test with an opt-in default-off test. - Add tests asserting `SPECIFY_ENABLE_UPDATE_CHECK=1` allows the fetch and that `CI=1` still suppresses it. - `uv run pytest tests/test_update_check.py` → 27 passed. - Full suite: 1301 passed, 20 skipped, 1 pre-existing unrelated failure (`test_without_force_errors_on_existing_dir`, Rich panel-wrap on `already exists`).
# Conflicts: # CHANGELOG.md
There was a problem hiding this comment.
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 4/4 changed files
- Comments generated: 2
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
Without this, pytest's stdout capture makes sys.stdout.isatty() return False under pytest, so the TTY guard alone would suppress the fetch and the assertion would still pass even if the CI guard were removed. Pinning isatty()=True ensures CI=1 is what's actually being verified. Addresses Copilot feedback on PR github#2212.
|
Hi @mnriem — round-2 Copilot feedback addressed in b75e55f:
All 27 tests in |
# Conflicts: # CHANGELOG.md
|
Resolved merge conflict with main in d8c16f7. Only |
There was a problem hiding this comment.
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:2018
- The update-check cache is only written on a successful fetch. If the user opts in but is offline / blocked,
_fetch_latest_version()returns None and no cache entry is recorded, so every CLI invocation will retry the network call (up to the timeout) instead of respecting the “≤1 per 24h” goal. Consider caching the attempt timestamp even on failures (e.g., storechecked_atwith a null/empty latest and treat that as a fresh cache hit that skips fetching until TTL expires).
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)
- Files reviewed: 4/4 changed files
- Comments generated: 4
|
Please address Copilot feedback. If not applicable, please explain why |
When the user has opted in via SPECIFY_ENABLE_UPDATE_CHECK=1 but the GitHub fetch fails (offline, rate-limited, blocked), record a negative cache entry (latest=null) and key the next decision off "was there a fresh cache hit" rather than "is latest_str None" — so the CLI does not retry the network on every invocation until the 24h TTL expires. Addresses Copilot review feedback on PR github#2212.
Drops local duplicates of get_speckit_version + the in-flight update-check helpers from __init__.py; the feature is re-added in _version.py in a follow-up commit so it reuses upstream's _get_installed_version, _fetch_latest_release_tag, and _is_newer from PR github#2550.
…mitives Following PR github#2550 which extracted version handling into _version.py, move the opt-in startup update-check helpers there too and replace our duplicates with upstream's: - _get_installed_version (was: local get_speckit_version with pyproject fallback) - _fetch_latest_release_tag (was: local _fetch_latest_version; gains auth via open_url) - _is_newer (was: local _parse_version_tuple; proper PEP 440 via packaging.Version) Behavior preserved: same opt-in env var, same 24h TTL, same negative caching, same CI/TTY skip guards. Cache file format unchanged.
|
@mnriem — also rebased onto current Tests:
Ready for another look, thanks! |
|
Addressed the latest Copilot feedback in c3e063d:
Checks:
|
| # 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 |
| ) | ||
| 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]" |
| latest_tag = cached.get("latest") | ||
| else: |
|
|
||
| ### 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) |
Summary
Addresses #1320 — when explicitly opted in, the CLI prints a one-line upgrade hint on launch if a newer release is available. Off by default; enabled with
SPECIFY_ENABLE_UPDATE_CHECK=1. Even when enabled, suppressed in CI (CI=1) and when stdout is not a TTY. Cached for 24h in the platform user-cache dir. Every network / parse failure is swallowed — the user's command is never failed by the check.Motivation
Observed in the wild: users running older CLIs (for example v0.3.0 still installed from PyPI, or v0.4.2 as in #2185) hit
No matching release asset found for claudewhen they tryspecify init --ai claude. The legacy asset-download path was removed in the Stage 6 migration (#2063) and the release workflow stopped producing those assets starting v0.4.5, so old clients have no recovery path and no signal that the fix is to upgrade the CLI. A launch-time update warning turns this silent failure into actionable guidance.This PR implements the spec in #1320 with one deliberate change requested in review: opt-in instead of opt-out, so air-gapped / network-constrained environments never reach GitHub by default.
SPECIFY_ENABLE_UPDATE_CHECK=1)Changes
src/specify_cli/__init__.pyget_speckit_version()):_parse_version_tuple()— tolerant parser (drops PEP 440 pre/post/dev/local segments)_update_check_cache_path()/_read_update_check_cache()/_write_update_check_cache()— JSON cache inplatformdirs.user_cache_dir("specify-cli")(UTF-8)_fetch_latest_version()—urllib.requestGET with 2s timeout; never raises_should_skip_update_check()— opt-in gate plus CI / non-TTY guards_check_for_updates()— top-level wrapper; all errors swallowedcallback()invokes_check_for_updates()for any non-versioninvocation (including barespecify);versionalready prints the installed version, so we skip there to avoid double-printing.platformdirsis already a declared dependency.tests/test_update_check.pyNew tests covering:
None)urlopensuccess / network error / malformed JSON / missing tagCI=1wins over the opt-in flag (with a pinnedisatty()so the CI guard is what's actually being tested)CHANGELOG.mdEntry under
## [Unreleased].docs/installation.md"Update Notifications" subsection documenting the opt-in env var and the suppression conditions (CI, non-TTY).
Test plan
uv run pytest tests/test_update_check.py— 27 passedSPECIFY_ENABLE_UPDATE_CHECK=1against a simulated outdated version: warning rendered, cache written, second invocation hit cache and skipped networkManual warning output
Notes for reviewers
1ffcbf9→81a7418→e45a36a→b75e55f):SPECIFY_ENABLE_UPDATE_CHECK(per @mnriem)str | Noneon_parse_version_tupleencoding="utf-8"on cache read/writespecifytootest_ci_suppresses_even_when_opted_inpinsisatty()=Trueso it actually verifies the CI guard