Files
github--spec-kit/tests/test_self_upgrade_verification.py
T
2026-07-13 11:57:40 +08:00

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