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 3 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
| 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 |
There was a problem hiding this comment.
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.
| def test_ci_suppresses_even_when_opted_in(self, monkeypatch, tmp_path): | ||
| """Belt-and-suspenders: CI=1 wins over the opt-in flag.""" | ||
| monkeypatch.setenv("SPECIFY_ENABLE_UPDATE_CHECK", "1") | ||
| monkeypatch.setenv("CI", "1") | ||
|
|
||
| fetched = {"called": False} | ||
|
|
||
| def _fetch(): | ||
| fetched["called"] = True | ||
| return "v99.0.0" | ||
|
|
||
| monkeypatch.setattr("specify_cli._fetch_latest_version", _fetch) | ||
| monkeypatch.setattr("specify_cli.get_speckit_version", lambda: "0.0.1") | ||
| monkeypatch.setattr( | ||
| "specify_cli._update_check_cache_path", lambda: tmp_path / "vc.json" | ||
| ) | ||
|
|
||
| _check_for_updates() | ||
|
|
||
| assert fetched["called"] is False |
There was a problem hiding this comment.
test_ci_suppresses_even_when_opted_in can pass even if the CI check is removed because sys.stdout.isatty() is often false under pytest capture. Patch sys.stdout.isatty to return True in this test (like the opt-in test does) so it deterministically verifies that CI=1 is the reason the network call is suppressed.
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
Summary
Addresses #1320 — the CLI now prints a one-line upgrade hint on launch when a newer release is available. Suppressed for non-interactive shells,
CI=1, orSPECIFY_SKIP_UPDATE_CHECK=1. Cached for 24h in the platform user-cache dir. Every network / parse failure is swallowed — the user's command is never blocked.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:
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\")_fetch_latest_version()—urllib.requestGET with 2s timeout; never raises_should_skip_update_check()— env-var / CI / non-TTY guard_check_for_updates()— top-level wrapper; all errors swallowedcallback()invokes_check_for_updates()for any non-versionsubcommand (versionalready surfaces the installed version, so we skip to avoid double-printing).platformdirsis already a declared dependency.tests/test_update_check.py25 new tests covering:
urlopensuccess / network error / malformed JSON / missing tagSPECIFY_SKIP_UPDATE_CHECK=1short-circuitsCHANGELOG.mdEntry under new
## [Unreleased]section.docs/installation.mdShort "Update Notifications" subsection listing the opt-out conditions.
Test plan
uv run pytest tests/test_update_check.py— 25 passeduv run pytest— full suite: 1299 passed, 20 skipped, 1 failed. The single failure (tests/integrations/test_cli.py::TestForceExistingDirectory::test_without_force_errors_on_existing_dir) also fails on unmodifiedmain— it's a pre-existing brittleness where Rich wrapsalready existsacross panel lines. Not caused by this PR.SPECIFY_SKIP_UPDATE_CHECK=1 uv run specify --help— banner renders, no warning, no crashManual warning output
Notes for reviewers
Opened as Draft per the CONTRIBUTING guidance on larger changes. The feature scope matches the maintainer-spec'd issue #1320 verbatim; happy to split, pare back, or rework before un-drafting if there's a preferred approach. Also related (but out of scope here): #2185 is a direct downstream symptom of the same class of problem this PR mitigates.