Skip to content

feat(cli): warn when a newer spec-kit release is available (#1320)#2212

Open
ATelbay wants to merge 5 commits intogithub:mainfrom
ATelbay:feat/update-check-1320
Open

feat(cli): warn when a newer spec-kit release is available (#1320)#2212
ATelbay wants to merge 5 commits intogithub:mainfrom
ATelbay:feat/update-check-1320

Conversation

@ATelbay
Copy link
Copy Markdown

@ATelbay ATelbay commented Apr 14, 2026

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 claude when they try specify 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.

  • Check latest release on launch (only when opted in)
  • Display upgrade command
  • Env-var opt-in (SPECIFY_ENABLE_UPDATE_CHECK=1)
  • Graceful offline handling
  • Cache ≤ 1 check per 24h

Changes

src/specify_cli/__init__.py

  • New private helpers (near get_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 in platformdirs.user_cache_dir("specify-cli") (UTF-8)
    • _fetch_latest_version()urllib.request GET 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 swallowed
  • callback() invokes _check_for_updates() for any non-version invocation (including bare specify); version already prints the installed version, so we skip there to avoid double-printing.
  • No new third-party deps — platformdirs is already a declared dependency.

tests/test_update_check.py

New tests covering:

  • Version-tuple parsing (happy paths, PEP 440 tails, garbage input, None)
  • Cache read/write (fresh, stale, missing, corrupt, round-trip)
  • urlopen success / network error / malformed JSON / missing tag
  • End-to-end helper behavior: warning text, no-op when up-to-date, cache-hit skips network, network failure is silent, opt-in default-off short-circuits, CI=1 wins over the opt-in flag (with a pinned isatty() so the CI guard is what's actually being tested)

CHANGELOG.md

Entry 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 passed
  • Manual smoke test — SPECIFY_ENABLE_UPDATE_CHECK=1 against a simulated outdated version: warning rendered, cache written, second invocation hit cache and skipped network
  • Default invocation (no env var) — banner renders, no warning, no network call

Manual warning output

⚠  A new spec-kit version is available: v0.6.2 (you have v0.3.0)
   Upgrade: uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@v0.6.2
   (unset SPECIFY_ENABLE_UPDATE_CHECK to disable this check)

Notes for reviewers

  • Review feedback addressed across two rounds (commits 1ffcbf981a7418e45a36ab75e55f):
    • Opt-out → opt-in via SPECIFY_ENABLE_UPDATE_CHECK (per @mnriem)
    • Type annotation str | None on _parse_version_tuple
    • Explicit encoding="utf-8" on cache read/write
    • Doc/comment wording: "never fails the command" (cache-miss adds bounded startup latency)
    • Update check now runs on bare specify too
    • test_ci_suppresses_even_when_opted_in pins isatty()=True so it actually verifies the CI guard
  • Related (out of scope): [Bug]: specify init fails with "No matching release asset found for claude" #2185 is a downstream symptom of the same class of problem this PR mitigates.

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.
@ATelbay ATelbay marked this pull request as ready for review April 14, 2026 08:08
@ATelbay ATelbay requested a review from mnriem as a code owner April 14, 2026 08:08
@mnriem mnriem requested a review from Copilot April 14, 2026 12:05
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.py suite covering version parsing, cache behavior, network/JSON failure swallowing, and end-to-end output behavior.
  • Document update notifications in docs/installation.md and 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_cache uses path.write_text(...) without an explicit encoding. For consistency with other file writes/reads in this module and to avoid platform default-encoding issues, specify encoding="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

Comment thread src/specify_cli/__init__.py Outdated
Comment thread src/specify_cli/__init__.py
Comment thread docs/installation.md Outdated
Comment thread src/specify_cli/__init__.py
Comment thread src/specify_cli/__init__.py Outdated
Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

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

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

ATelbay added 2 commits April 21, 2026 12:27
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`).
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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

Comment thread src/specify_cli/__init__.py
Comment thread tests/test_update_check.py
@mnriem mnriem self-requested a review April 21, 2026 13:13
Copy link
Copy Markdown
Collaborator

@mnriem mnriem left a comment

Choose a reason for hiding this comment

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

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.
@ATelbay
Copy link
Copy Markdown
Author

ATelbay commented Apr 28, 2026

Hi @mnriem — round-2 Copilot feedback addressed in b75e55f:

  • Test brittleness (tests/test_update_check.py:271): test_ci_suppresses_even_when_opted_in now pins sys.stdout.isatty()=True, so it actually verifies the CI guard instead of short-circuiting on the TTY check (verified locally — removing the CI guard makes the test fail).
  • PR description / spec mismatch (src/specify_cli/__init__.py:1797): rewrote the PR body so it matches the shipped opt-in behavior (SPECIFY_ENABLE_UPDATE_CHECK=1) — the previous body still referenced the original opt-out wording.

All 27 tests in tests/test_update_check.py pass. Ready for another look whenever you have a moment, thanks!

@ATelbay
Copy link
Copy Markdown
Author

ATelbay commented Apr 28, 2026

Resolved merge conflict with main in d8c16f7. Only CHANGELOG.md had a real conflict — kept the existing ## [Unreleased] entry (the update-check feature) above the new ## [0.8.1] section that landed on main. src/specify_cli/__init__.py and docs/installation.md auto-merged cleanly. Verified locally: tests/test_update_check.py — 27/27 pass; remaining suite — 838 passed, 29 skipped, 2 unrelated pre-existing failures in tests/test_workflows.py (Gemini CLI quota timeouts, not caused by this PR).

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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., store checked_at with 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 == ""

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
# 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"
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
_UPDATE_CHECK_URL = "https://api.github.com/repos/github/spec-kit/releases/latest"
_UPDATE_CHECK_URL = GITHUB_API_LATEST

Copilot uses AI. Check for mistakes.
Comment thread CHANGELOG.md

### 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)
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
- 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)

Copilot uses AI. Check for mistakes.
Comment thread docs/installation.md
Comment on lines +99 to +100
`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.

Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented Apr 28, 2026

Please address Copilot feedback. If not applicable, please explain why

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants