mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +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.
185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
"""Non-upgradable path guidance tests for `specify self upgrade`."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
from specify_cli import app
|
|
|
|
from tests.self_upgrade_helpers import (
|
|
mock_urlopen_response,
|
|
runner,
|
|
strip_ansi,
|
|
)
|
|
|
|
# ===========================================================================
|
|
# Phase 5 — User Story 3: non-upgradable path guidance (P3)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestUvxEphemeral:
|
|
"""uvx ephemeral path emits exact one-liner, no installer call."""
|
|
|
|
def test_uvx_argv0_prints_exact_one_liner_and_exits_zero(
|
|
self,
|
|
uvx_ephemeral_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 0
|
|
expected = (
|
|
"Running via uvx (ephemeral); the next uvx invocation already "
|
|
"resolves to latest — no upgrade action needed."
|
|
)
|
|
assert expected in strip_ansi(result.output)
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_offline_still_exits_zero_without_tag_resolution(
|
|
self,
|
|
uvx_ephemeral_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=AssertionError("non-upgradable uvx path must not hit network"),
|
|
):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 0
|
|
assert "uvx (ephemeral)" in strip_ansi(result.output)
|
|
|
|
|
|
class TestSourceCheckout:
|
|
"""Editable install path emits git pull guidance."""
|
|
|
|
def test_source_checkout_prints_git_pull_guidance(
|
|
self,
|
|
unsupported_argv0,
|
|
tmp_path,
|
|
clean_environ,
|
|
):
|
|
fake_tree = tmp_path / "worktree"
|
|
fake_tree.mkdir()
|
|
(fake_tree / ".git").mkdir()
|
|
|
|
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
|
"specify_cli._version._source_checkout_path", return_value=fake_tree
|
|
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert f"Running from a source checkout at {fake_tree}" in out
|
|
assert "git pull" in out
|
|
assert "pip install -e ." in out
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_source_checkout_without_path_mentions_checkout_directory(
|
|
self,
|
|
unsupported_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli._version._editable_marker_seen", return_value=True), patch(
|
|
"specify_cli._version._source_checkout_path", return_value=None
|
|
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
out = strip_ansi(result.output)
|
|
assert result.exit_code == 0
|
|
assert "checkout path could not be detected" in out
|
|
assert "from your checkout directory" in out
|
|
assert "(path unavailable)" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
|
|
class TestUnsupported:
|
|
"""Unsupported path enumerates manual reinstall commands."""
|
|
|
|
def test_unsupported_prints_both_reinstall_commands(
|
|
self,
|
|
unsupported_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
|
"specify_cli._version.shutil.which", return_value=None
|
|
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Could not identify your install method automatically" in out
|
|
assert (
|
|
"uv tool install specify-cli --force --from "
|
|
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
|
) in out
|
|
assert (
|
|
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
|
in out
|
|
)
|
|
assert mock_run.call_count == 0
|
|
|
|
def test_unsupported_offline_degrades_to_placeholder_manual_commands(
|
|
self,
|
|
unsupported_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
|
"specify_cli._version.shutil.which", return_value=None
|
|
), patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=AssertionError("unsupported guidance should not require network"),
|
|
):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Could not identify your install method automatically" in out
|
|
assert (
|
|
"uv tool install specify-cli --force --from "
|
|
"git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
|
) in out
|
|
assert (
|
|
"pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z"
|
|
in out
|
|
)
|
|
|
|
|
|
class TestDryRunNonUpgradablePaths:
|
|
"""--dry-run on non-upgradable paths emits guidance, not preview."""
|
|
|
|
def test_dry_run_on_uvx_ephemeral_emits_guidance_not_preview(
|
|
self,
|
|
uvx_ephemeral_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Dry run — no changes will be made." not in out
|
|
assert "uvx (ephemeral)" in out
|
|
|
|
def test_dry_run_on_unsupported_emits_manual_commands(
|
|
self,
|
|
unsupported_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli._version._editable_marker_seen", return_value=False), patch(
|
|
"specify_cli._version.shutil.which", return_value=None
|
|
), patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen:
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
result = runner.invoke(app, ["self", "upgrade", "--dry-run"])
|
|
assert result.exit_code == 0
|
|
assert "Could not identify your install method" in strip_ansi(result.output)
|