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 5 commits intogithub:mainfrom
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
| out = self._run_and_capture(monkeypatch) | ||
|
|
||
| assert out == "" | ||
|
|
There was a problem hiding this comment.
Given the intended “≤1 check per 24h” behavior, there’s no test ensuring that an offline/failed fetch is still rate-limited (i.e., a second invocation within TTL does not reattempt _fetch_latest_version). If the implementation is updated to cache failures/backoff, add a regression test that runs _check_for_updates() twice with _fetch_latest_version returning None the first time and asserts the second run does not call it again.
| def test_failed_fetch_is_rate_limited_within_ttl(self, monkeypatch, tmp_path): | |
| cache_file = tmp_path / "vc.json" | |
| monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.6.2") | |
| monkeypatch.setattr("specify_cli._update_check_cache_path", lambda: cache_file) | |
| call_counter = {"n": 0} | |
| def _fetch_once_per_ttl() -> str | None: | |
| call_counter["n"] += 1 | |
| return None | |
| monkeypatch.setattr("specify_cli._fetch_latest_version", _fetch_once_per_ttl) | |
| first_out = self._run_and_capture(monkeypatch) | |
| second_out = self._run_and_capture(monkeypatch) | |
| assert first_out == "" | |
| assert second_out == "" | |
| assert call_counter["n"] == 1 |
| # 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" |
There was a problem hiding this comment.
This code introduces a second copy of the “latest release” GitHub API URL (_UPDATE_CHECK_URL), even though the module already defines GITHUB_API_LATEST (and has existing fetch helpers for self-check). To avoid drift/inconsistent behavior over time (headers, auth token support, timeouts, etc.), consider reusing the existing constant and/or factoring a shared fetch helper that both features call with different timeouts/error-handling.
This issue also appears on line 2014 of the same file.
| _UPDATE_CHECK_URL = "https://api.github.com/repos/github/spec-kit/releases/latest" | |
| _UPDATE_CHECK_URL = GITHUB_API_LATEST |
|
|
||
| ### 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) |
There was a problem hiding this comment.
The changelog entry says the warning is suppressed in CI=1, but the implementation suppresses whenever the CI environment variable is set to any value. Consider rewording to “suppressed when CI is set” to match actual behavior.
| - 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) | |
| - 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) |
| `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. | ||
|
|
There was a problem hiding this comment.
This section implies the check is limited to once per 24 hours, but the current implementation only caches successful fetches; if the user opts in and is offline/blocked, it will retry on every invocation (up to the timeout). Either update the wording to reflect that behavior, or adjust the code to cache failed attempts too so it truly rate-limits to once per TTL.
|
Please address Copilot feedback. If not applicable, please explain why |
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