mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* feat(cli): implement specify self upgrade
* fix(cli): normalize self-upgrade prerelease tags
* fix(cli): tighten self-upgrade diagnostics
* fix(cli): harden self-upgrade verification parsing
* fix(cli): sanitize self-check fallback tags
* fix(cli): harden self-check release display
* fix(cli): validate resolved upgrade tags
* fix(cli): tolerate invalid install metadata
* test(cli): align upgrade network mocks
* fix(cli): respect relative installer paths
* fix(cli): tighten upgrade failure handling
* fix(cli): align installer path diagnostics
* fix(cli): validate release and version output
* fix(cli): clarify source checkout guidance
* fix(cli): harden upgrade detection helpers
* fix(cli): avoid echoing invalid release tags
* fix(cli): tolerate argv path resolve failures
* chore: remove self-upgrade formatting-only diffs
* fix: address self-upgrade review feedback
* fix: address self-upgrade review followups
* fix: address self-upgrade review edge cases
* fix: address self-upgrade review docs
* fix: refine self-upgrade review followups
* fix: address self-upgrade review cleanup
* fix: handle self-upgrade review edge cases
* fix: address self-upgrade review nits
* fix: address follow-up self-upgrade review
* fix: resolve self-upgrade review and Windows CI failures
- README: promote "Optional Commands" to ### so it is a sibling of
"Core Commands" under "Available Slash Commands" (consistent heading
levels; avoids the h2->h4 jump a revert would create).
- _version: allow --tag prerelease/dev and build-metadata suffixes to
compose (e.g. v1.0.0-rc1+build.42), matching PEP 440 / semver; the
Version() check still enforces canonical validity.
- tests: compare resolved argv0 as Path objects instead of POSIX strings
so the assertion holds on Windows; skip the relative-installer-path
executable-bit tests on Windows via a new requires_posix marker (they
rely on chmod/X_OK semantics and chdir-into-tmp teardown that do not
hold there). Add a combined prerelease+build-metadata tag test.
* fix: address second self-upgrade review round
- self_check: clarify that the "up to date" branch is reached only for
parseable latest tags (the unparseable case returns earlier), so the
InvalidVersion fallback assumption is not reintroduced.
- self_upgrade: compare target/current as Version instances directly
instead of re-parsing the canonical strings through _is_newer; the
empty-current case stays explicit via the not-None guard.
- tests: document the intentional broad GH_/GITHUB_ env scrub with a test
asserting non-credential context vars (GH_HOST, GITHUB_REPOSITORY, …) are
stripped from the installer subprocess env — a deliberate fail-safe that
also catches credential-adjacent names without a recognized suffix.
* fix: address third self-upgrade review round
- self_upgrade: unify the no-op short-circuits on packaging Version
equality instead of canonical-string equality. Version("1.0") equals
Version("1.0.0") but their str() forms differ, so the old check could
misreport an equal install as "already on latest release or newer".
Both the unpinned and pinned branches now use Version comparison.
- self_upgrade: compare the verified version as a parsed Version against
the target so a non-version verifier result is a mismatch (exit 2)
rather than a coincidental canonical-string match.
- resolver: map HTTP 429 (Too Many Requests / secondary rate limit) to
the rate-limited category so users get the same actionable token hint
as 403.
- _is_github_credential_env_key: document the precise (intentionally
broad) scrub matching contract in the docstring.
- tests: add a trailing-zero Version-equality regression test and a
parametrized HTTP-status categorization test (429 -> rate limited;
404/502 -> verbatim).
* fix: address fourth self-upgrade review round
- self_upgrade: label a pinned target older than the installed version as
"Downgrading" rather than "Upgrading" so `--tag <older>` is not mistaken
for a forward upgrade.
- resolver: drop the unused `typing.Optional` import and annotate the
`--tag` option as `str | None`, consistent with the rest of the module
(verified Typer resolves it on the supported Python versions).
- _is_github_credential_env_key: add `_PASSWORD` and `_CREDENTIALS` to the
recognized credential suffixes and document that only these shapes are
scrubbed (not blanket coverage).
- tests: assert the precise exit code (1) for the re-raised transient
OSError path; skip the InvalidMetadataError test on Pythons where the
real exception is absent instead of fabricating it; update the pinned
downgrade test to expect the "Downgrading" label.
* fix: accept uppercase V prefix in --tag
Fold a leading uppercase `V` (a common paste) to the canonical lowercase
`v` before validating `--tag`. The remainder of the tag stays
case-sensitive on purpose: the validated value is used verbatim as a git
ref, which is case-sensitive on GitHub, so rewriting label/build-metadata
casing could point at a tag that does not exist. Adds a normalization test.
153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""Shared test helpers for the Spec Kit test suite."""
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
|
|
|
|
|
def _has_working_bash() -> bool:
|
|
"""Check whether a functional native bash is available.
|
|
|
|
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
|
|
which searches System32 *before* PATH — so it may find the WSL
|
|
launcher even when Git-for-Windows bash appears first in PATH via
|
|
``shutil.which``. We therefore probe with bare ``"bash"`` (the
|
|
same way test helpers invoke it) to get an accurate result.
|
|
|
|
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
|
|
The WSL launcher is rejected because it runs in a separate Linux
|
|
filesystem and cannot handle native Windows paths used by the
|
|
test fixtures.
|
|
|
|
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
|
|
"""
|
|
if os.environ.get("SPECKIT_TEST_BASH") == "1":
|
|
return True
|
|
if shutil.which("bash") is None:
|
|
return False
|
|
# Probe with bare "bash" — same as the test helpers — so that
|
|
# Windows CreateProcess resolution order is respected.
|
|
try:
|
|
r = subprocess.run(
|
|
["bash", "-c", "echo ok"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
if r.returncode != 0 or "ok" not in r.stdout:
|
|
return False
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
|
|
# not the WSL launcher which can't handle native paths.
|
|
if sys.platform == "win32":
|
|
try:
|
|
u = subprocess.run(
|
|
["bash", "-c", "uname -s"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
kernel = u.stdout.strip().upper()
|
|
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
|
|
return False
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return True
|
|
|
|
|
|
requires_bash = pytest.mark.skipif(
|
|
not _has_working_bash(), reason="working bash not available"
|
|
)
|
|
|
|
|
|
def strip_ansi(text: str) -> str:
|
|
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
|
return _ANSI_ESCAPE_RE.sub("", text)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_auth_config(monkeypatch):
|
|
"""Ensure no test reads the real ~/.specify/auth.json."""
|
|
from specify_cli.authentication import http as _auth_http
|
|
monkeypatch.setattr(_auth_http, "_config_override", [])
|
|
# Also clear the per-process cache so tests that unset _config_override
|
|
# won't see a previously cached real-file result.
|
|
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_environ(monkeypatch):
|
|
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
|
monkeypatch.delenv("GH_TOKEN", raising=False)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
|
|
|
|
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
|
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
|
monkeypatch.setenv(env_name, str(tmp_path))
|
|
fake_dir = tmp_path.joinpath(*path_parts)
|
|
fake_dir.mkdir(parents=True)
|
|
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
|
fake_specify.write_text("#!/usr/bin/env python\n")
|
|
fake_specify.chmod(0o755)
|
|
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
|
return fake_specify
|
|
|
|
|
|
@pytest.fixture
|
|
def uv_tool_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch,
|
|
tmp_path,
|
|
"HOME",
|
|
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def pipx_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
|
if os.name == "nt":
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch,
|
|
tmp_path,
|
|
"LOCALAPPDATA",
|
|
("uv", "cache", "archive-v0", "abc123", "bin"),
|
|
)
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def unsupported_argv0(monkeypatch, tmp_path):
|
|
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
|
return _fake_self_upgrade_argv0(
|
|
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
|
)
|