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.
650 lines
27 KiB
Python
650 lines
27 KiB
Python
"""Verification, resolution, and validation tests for `specify self upgrade`."""
|
|
|
|
import urllib.error
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
import specify_cli
|
|
from specify_cli import app
|
|
|
|
from tests.self_upgrade_helpers import (
|
|
SENTINEL_GH_TOKEN,
|
|
SENTINEL_GITHUB_TOKEN,
|
|
_InstallMethod,
|
|
_UpgradePlan,
|
|
_completed_process,
|
|
_verify_upgrade,
|
|
mock_urlopen_response,
|
|
runner,
|
|
strip_ansi,
|
|
)
|
|
|
|
# ===========================================================================
|
|
# Phase 6 — User Story 4: failure recovery (P2)
|
|
# ===========================================================================
|
|
|
|
|
|
class TestVerificationMismatch:
|
|
"""Installer says 0 but the binary is still the old version → exit 2."""
|
|
|
|
def test_installer_ok_but_verify_returns_old_version(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0), # installer OK
|
|
_completed_process(0, stdout="specify 0.7.5\n"), # verify: OLD!
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 2
|
|
out = strip_ansi(result.output)
|
|
assert "Verification failed" in out
|
|
assert "resolves to 0.7.5 (expected v0.7.6)" in out
|
|
assert "The new version may take effect on your next invocation." in out
|
|
|
|
def test_verify_nonzero_exit_is_not_treated_as_success(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(1, stdout="specify 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 2
|
|
out = strip_ansi(result.output)
|
|
assert "Verification failed" in out
|
|
assert "(unknown) (expected v0.7.6)" in out
|
|
|
|
def test_verify_accepts_pep440_equivalent_rc_version(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.9.0"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v9.9.9"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 1.0.0rc1\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade", "--tag", "v1.0.0-rc1"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Upgraded specify-cli: 0.9.0 → 1.0.0rc1" in strip_ansi(result.output)
|
|
|
|
def test_verify_accepts_specify_cli_binary_name_in_version_output(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify-cli version 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
|
|
|
def test_verify_accepts_capitalized_binary_name_in_version_output(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="Specify, version 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Upgraded specify-cli: 0.7.5 → 0.7.6" in strip_ansi(result.output)
|
|
|
|
def test_verify_rejects_output_without_parseable_version(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify version unknown\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 2
|
|
out = strip_ansi(result.output)
|
|
assert "Verification failed" in out
|
|
assert "(unknown) (expected v0.7.6)" in out
|
|
|
|
def test_verify_uses_current_entrypoint_when_not_on_path(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
assert uv_tool_argv0.exists()
|
|
assert uv_tool_argv0.is_file()
|
|
|
|
plan = _UpgradePlan(
|
|
method=_InstallMethod.UV_TOOL,
|
|
current_version="0.7.5",
|
|
target_tag="v0.7.6",
|
|
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
|
preview_summary="",
|
|
pre_upgrade_snapshot="0.7.5",
|
|
)
|
|
|
|
with patch(
|
|
"specify_cli._version.shutil.which", side_effect=lambda name: None
|
|
), patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.os.access", return_value=True
|
|
):
|
|
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
|
verified = _verify_upgrade(plan)
|
|
|
|
assert verified == "0.7.6"
|
|
assert mock_run.call_args.args[0][0] == str(uv_tool_argv0)
|
|
assert mock_run.call_args.kwargs["timeout"] == specify_cli._version._VERIFY_TIMEOUT_SECS
|
|
|
|
def test_verify_falls_back_to_path_when_current_entrypoint_is_not_executable(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
plan = _UpgradePlan(
|
|
method=_InstallMethod.UV_TOOL,
|
|
current_version="0.7.5",
|
|
target_tag="v0.7.6",
|
|
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
|
preview_summary="",
|
|
pre_upgrade_snapshot="0.7.5",
|
|
)
|
|
|
|
with patch(
|
|
"specify_cli._version.shutil.which",
|
|
side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None,
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version.os.access", return_value=False
|
|
):
|
|
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
|
verified = _verify_upgrade(plan)
|
|
|
|
assert verified == "0.7.6"
|
|
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
|
|
|
def test_verify_ignores_python_entrypoint_and_falls_back_to_specify(
|
|
self,
|
|
clean_environ,
|
|
tmp_path,
|
|
):
|
|
fake_python = tmp_path / "python3"
|
|
fake_python.write_text("#!/bin/sh\n")
|
|
fake_python.chmod(0o755)
|
|
|
|
plan = _UpgradePlan(
|
|
method=_InstallMethod.UV_TOOL,
|
|
current_version="0.7.5",
|
|
target_tag="v0.7.6",
|
|
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
|
preview_summary="",
|
|
pre_upgrade_snapshot="0.7.5",
|
|
)
|
|
|
|
with patch(
|
|
"specify_cli._version.shutil.which", side_effect=lambda name: "/usr/local/bin/specify" if name == "specify" else None
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version.sys.argv", [str(fake_python)]
|
|
), patch(
|
|
"specify_cli._version.os.access", return_value=True
|
|
):
|
|
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
|
verified = _verify_upgrade(plan)
|
|
|
|
assert verified == "0.7.6"
|
|
assert mock_run.call_args.args[0][0] == "/usr/local/bin/specify"
|
|
|
|
def test_verify_accepts_specify_cli_named_current_entrypoint(
|
|
self,
|
|
clean_environ,
|
|
tmp_path,
|
|
):
|
|
fake_specify_cli = tmp_path / "specify-cli"
|
|
fake_specify_cli.write_text("#!/bin/sh\n")
|
|
fake_specify_cli.chmod(0o755)
|
|
|
|
plan = _UpgradePlan(
|
|
method=_InstallMethod.UV_TOOL,
|
|
current_version="0.7.5",
|
|
target_tag="v0.7.6",
|
|
installer_argv=["/usr/bin/uv", "tool", "install", "specify-cli"],
|
|
preview_summary="",
|
|
pre_upgrade_snapshot="0.7.5",
|
|
)
|
|
|
|
with patch("specify_cli._version.shutil.which", return_value=None), patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch("specify_cli._version.sys.argv", [str(fake_specify_cli)]), patch(
|
|
"specify_cli._version.os.access", return_value=True
|
|
):
|
|
mock_run.return_value = _completed_process(0, stdout="specify 0.7.6\n")
|
|
verified = _verify_upgrade(plan)
|
|
|
|
assert verified == "0.7.6"
|
|
assert mock_run.call_args.args[0][0] == str(fake_specify_cli)
|
|
|
|
|
|
class TestResolutionFailures:
|
|
"""Pre-installer resolution failure → exit 1, reusing the resolver category strings."""
|
|
|
|
def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ):
|
|
with patch(
|
|
"specify_cli.authentication.http.urllib.request.urlopen",
|
|
side_effect=urllib.error.URLError("nope"),
|
|
):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 1
|
|
assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output)
|
|
|
|
def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ):
|
|
err = urllib.error.HTTPError(
|
|
url="https://api.github.com",
|
|
code=403,
|
|
msg="rate limited",
|
|
hdrs={}, # type: ignore[arg-type]
|
|
fp=None,
|
|
)
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 1
|
|
assert (
|
|
"Upgrade aborted: rate limited (configure ~/.specify/auth.json with a GitHub token)"
|
|
in strip_ansi(result.output)
|
|
)
|
|
|
|
def test_http_500_exits_1(self, uv_tool_argv0, clean_environ):
|
|
err = urllib.error.HTTPError(
|
|
url="https://api.github.com",
|
|
code=500,
|
|
msg="srv err",
|
|
hdrs={}, # type: ignore[arg-type]
|
|
fp=None,
|
|
)
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 1
|
|
assert "Upgrade aborted: HTTP 500" in strip_ansi(result.output)
|
|
|
|
@pytest.mark.parametrize(
|
|
"code, expected",
|
|
[
|
|
# 429 (Too Many Requests / secondary rate limit) gets the same
|
|
# actionable token hint as 403; other statuses surface verbatim.
|
|
(
|
|
429,
|
|
"Upgrade aborted: rate limited (configure ~/.specify/auth.json "
|
|
"with a GitHub token)",
|
|
),
|
|
(404, "Upgrade aborted: HTTP 404"),
|
|
(502, "Upgrade aborted: HTTP 502"),
|
|
],
|
|
)
|
|
def test_http_error_categorization(
|
|
self, code, expected, uv_tool_argv0, clean_environ
|
|
):
|
|
err = urllib.error.HTTPError(
|
|
url="https://api.github.com",
|
|
code=code,
|
|
msg="err",
|
|
hdrs={}, # type: ignore[arg-type]
|
|
fp=None,
|
|
)
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=err):
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
assert result.exit_code == 1
|
|
assert expected in strip_ansi(result.output)
|
|
|
|
def test_unparseable_resolved_release_tag_exits_1_without_traceback(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.subprocess.run"
|
|
) as mock_run, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version._get_installed_version", return_value="0.7.5"):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "release-main"})
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 1
|
|
out = strip_ansi(result.output)
|
|
assert "resolved release tag is not a comparable version" in out
|
|
assert "release-main" not in out
|
|
assert "Traceback" not in out
|
|
assert mock_run.call_count == 0
|
|
|
|
|
|
class TestTagValidation:
|
|
"""--tag regex enforcement."""
|
|
|
|
def test_valid_stable_tag(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v0.7.6"],
|
|
)
|
|
assert result.exit_code == 0
|
|
|
|
def test_valid_dev_suffix_tag(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v0.8.0.dev0"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Target version: v0.8.0.dev0" in strip_ansi(result.output)
|
|
|
|
def test_valid_rc_tag(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1"],
|
|
)
|
|
assert result.exit_code == 0
|
|
|
|
def test_valid_beta_dot_tag_uses_pep440_equivalent_for_noop(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="1.0.0b1"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--tag", "v1.0.0-beta.1"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Already on requested release: v1.0.0-beta.1" in strip_ansi(
|
|
result.output
|
|
)
|
|
|
|
def test_valid_build_metadata_tag(self, uv_tool_argv0, clean_environ):
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v0.8.0+build.42"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Target version: v0.8.0+build.42" in strip_ansi(result.output)
|
|
|
|
def test_uppercase_v_prefix_is_folded_to_lowercase(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
# A pasted uppercase `V` prefix is accepted and normalized to `v` so
|
|
# the git ref matches the canonical lowercase release tag.
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "V0.7.6"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Target version: v0.7.6" in strip_ansi(result.output)
|
|
|
|
def test_valid_prerelease_with_build_metadata_tag(
|
|
self, uv_tool_argv0, clean_environ
|
|
):
|
|
# Prerelease and build-metadata suffixes compose (PEP 440 / semver).
|
|
with patch("specify_cli._version.shutil.which", return_value="uv"), patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
result = runner.invoke(
|
|
app,
|
|
["self", "upgrade", "--dry-run", "--tag", "v1.0.0-rc1+build.42"],
|
|
)
|
|
assert result.exit_code == 0
|
|
assert "Target version: v1.0.0-rc1+build.42" in strip_ansi(result.output)
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_tag",
|
|
[
|
|
"latest",
|
|
"0.7.5",
|
|
"main",
|
|
"v7",
|
|
"",
|
|
"v1.2.3abc",
|
|
"v1.2.3...",
|
|
"v1.2.3++",
|
|
"v\uff11.2.3",
|
|
"v1.\u0662.3",
|
|
],
|
|
)
|
|
def test_invalid_tags_rejected(self, bad_tag, uv_tool_argv0, clean_environ):
|
|
result = runner.invoke(app, ["self", "upgrade", "--tag", bad_tag])
|
|
assert result.exit_code == 1
|
|
output = strip_ansi(result.output)
|
|
assert "Invalid --tag" in output or "expected vMAJOR.MINOR.PATCH" in output
|
|
|
|
|
|
class TestUnknownCurrent:
|
|
"""'unknown' current version renders literally in notice and success message."""
|
|
|
|
def test_unknown_current_renders_literal_in_notice(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="unknown"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 0
|
|
out = strip_ansi(result.output)
|
|
assert "Upgrading specify-cli unknown → v0.7.6 via uv tool:" in out
|
|
assert "Upgraded specify-cli: unknown → 0.7.6" in out
|
|
|
|
def test_unknown_current_rollback_hint_degrades(
|
|
self,
|
|
uv_tool_argv0,
|
|
clean_environ,
|
|
):
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="unknown"
|
|
):
|
|
mock_urlopen.return_value = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
mock_run.side_effect = [_completed_process(2)] # installer fails
|
|
result = runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert result.exit_code == 2
|
|
out = strip_ansi(result.output)
|
|
assert "Could not determine the previous version" in out
|
|
assert "https://github.com/github/spec-kit/releases" in out
|
|
|
|
|
|
class TestTokenScrubbing:
|
|
"""GH_TOKEN / GITHUB_TOKEN are stripped from every child env."""
|
|
|
|
def test_env_passed_to_subprocess_has_no_github_tokens(
|
|
self,
|
|
uv_tool_argv0,
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("GH_TOKEN", SENTINEL_GH_TOKEN)
|
|
monkeypatch.setenv("GITHUB_TOKEN", SENTINEL_GITHUB_TOKEN)
|
|
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli.authentication.http.urllib.request.build_opener"
|
|
) as mock_build_opener, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = response
|
|
mock_build_opener.return_value.open.return_value = response
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert mock_run.call_count >= 1
|
|
for call in mock_run.call_args_list:
|
|
env_kwarg = call.kwargs.get("env") or {}
|
|
assert "GH_TOKEN" not in env_kwarg, f"env leaked GH_TOKEN: {env_kwarg!r}"
|
|
assert "GITHUB_TOKEN" not in env_kwarg
|
|
for v in env_kwarg.values():
|
|
assert SENTINEL_GH_TOKEN not in v
|
|
assert SENTINEL_GITHUB_TOKEN not in v
|
|
|
|
def test_env_scrubbing_is_case_insensitive(
|
|
self,
|
|
uv_tool_argv0,
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("gh_token", SENTINEL_GH_TOKEN)
|
|
monkeypatch.setenv("GitHub_Token", SENTINEL_GITHUB_TOKEN)
|
|
response = mock_urlopen_response({"tag_name": "v0.7.6"})
|
|
|
|
with patch("specify_cli.authentication.http.urllib.request.urlopen") as mock_urlopen, patch(
|
|
"specify_cli.authentication.http.urllib.request.build_opener"
|
|
) as mock_build_opener, patch(
|
|
"specify_cli._version.shutil.which", return_value="uv"
|
|
), patch("specify_cli._version.subprocess.run") as mock_run, patch(
|
|
"specify_cli._version._get_installed_version", return_value="0.7.5"
|
|
):
|
|
mock_urlopen.return_value = response
|
|
mock_build_opener.return_value.open.return_value = response
|
|
mock_run.side_effect = [
|
|
_completed_process(0),
|
|
_completed_process(0, stdout="specify 0.7.6\n"),
|
|
]
|
|
runner.invoke(app, ["self", "upgrade"])
|
|
|
|
assert mock_run.call_count >= 1
|
|
for call in mock_run.call_args_list:
|
|
env_kwarg = call.kwargs.get("env") or {}
|
|
assert "gh_token" not in env_kwarg
|
|
assert "GitHub_Token" not in env_kwarg
|
|
for v in env_kwarg.values():
|
|
assert SENTINEL_GH_TOKEN not in v
|
|
assert SENTINEL_GITHUB_TOKEN not in v
|
|
|
|
def test_env_scrubbing_removes_github_token_variants(self, monkeypatch):
|
|
monkeypatch.setenv("GH_PAT", "gh-pat")
|
|
monkeypatch.setenv("GH_TOKEN_FILE", "gh-token-file")
|
|
monkeypatch.setenv("GH_ENTERPRISE_TOKEN", "enterprise-gh")
|
|
monkeypatch.setenv("GH_ENTERPRISE_SECRET", "enterprise-secret")
|
|
monkeypatch.setenv("GH_ENTERPRISE_PRIVATE_KEY", "enterprise-key")
|
|
monkeypatch.setenv("GITHUB_PAT", "github-pat")
|
|
monkeypatch.setenv("GITHUB_TOKEN_PATH", "github-token-path")
|
|
monkeypatch.setenv("GITHUB_ENTERPRISE_TOKEN", "enterprise-github")
|
|
monkeypatch.setenv("GITHUB_API_TOKEN", "api-token")
|
|
monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", "app-private-key")
|
|
monkeypatch.setenv("GITHUB_OAUTH_CLIENT_SECRET", "oauth-secret")
|
|
monkeypatch.setenv("HOMEBREW_GITHUB_API_TOKEN", "homebrew-token")
|
|
monkeypatch.setenv("NOTGITHUB_TOKEN", "not-github-kept")
|
|
monkeypatch.setenv("GHOST_API_TOKEN", "ghost-kept")
|
|
monkeypatch.setenv("GHIDRA_API_KEY", "ghidra-kept")
|
|
monkeypatch.setenv("UNRELATED_TOKEN", "kept")
|
|
|
|
env = specify_cli._version._scrubbed_env()
|
|
|
|
assert "GH_PAT" not in env
|
|
assert "GH_TOKEN_FILE" not in env
|
|
assert "GH_ENTERPRISE_TOKEN" not in env
|
|
assert "GH_ENTERPRISE_SECRET" not in env
|
|
assert "GH_ENTERPRISE_PRIVATE_KEY" not in env
|
|
assert "GITHUB_PAT" not in env
|
|
assert "GITHUB_TOKEN_PATH" not in env
|
|
assert "GITHUB_ENTERPRISE_TOKEN" not in env
|
|
assert "GITHUB_API_TOKEN" not in env
|
|
assert "GITHUB_APP_PRIVATE_KEY" not in env
|
|
assert "GITHUB_OAUTH_CLIENT_SECRET" not in env
|
|
assert "HOMEBREW_GITHUB_API_TOKEN" not in env
|
|
assert env["NOTGITHUB_TOKEN"] == "not-github-kept"
|
|
assert env["GHOST_API_TOKEN"] == "ghost-kept"
|
|
assert env["GHIDRA_API_KEY"] == "ghidra-kept"
|
|
assert env["UNRELATED_TOKEN"] == "kept"
|
|
|
|
def test_env_scrubbing_strips_noncredential_github_vars_by_design(
|
|
self, monkeypatch
|
|
):
|
|
# The scrub is intentionally broad: every GH_/GITHUB_-prefixed name is
|
|
# removed from the installer subprocess env, including non-credential
|
|
# context vars. This is a deliberate fail-safe so credential-adjacent
|
|
# names that lack a recognized suffix (e.g. GH_TOKEN_FILE,
|
|
# GITHUB_TOKEN_PATH, asserted above) can never leak. The installer
|
|
# (`uv tool install` / `pipx install` of a public package) does not
|
|
# consume routing/context vars like GITHUB_REPOSITORY, so nothing the
|
|
# subprocess needs is lost by stripping them.
|
|
monkeypatch.setenv("GH_HOST", "github.example.com")
|
|
monkeypatch.setenv("GH_CONFIG_DIR", "/home/u/.config/gh")
|
|
monkeypatch.setenv("GITHUB_REPOSITORY", "github/spec-kit")
|
|
monkeypatch.setenv("GITHUB_WORKSPACE", "/home/runner/work")
|
|
monkeypatch.setenv("GITHUB_USER", "octocat")
|
|
|
|
env = specify_cli._version._scrubbed_env()
|
|
|
|
assert "GH_HOST" not in env
|
|
assert "GH_CONFIG_DIR" not in env
|
|
assert "GITHUB_REPOSITORY" not in env
|
|
assert "GITHUB_WORKSPACE" not in env
|
|
assert "GITHUB_USER" not in env
|