diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index c2ebfbe0..8de42570 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -21,6 +21,8 @@ jobs: target: macos-x86_64 - os: ubuntu-22.04 target: linux-x86_64 + - os: ubuntu-22.04-arm + target: linux-arm64 - os: windows-latest target: windows-x86_64 @@ -43,6 +45,20 @@ jobs: uv sync --frozen uv run pyinstaller strix.spec --noconfirm + if [[ "${{ runner.os }}" == "Windows" ]]; then + dist/strix.exe --version + else + dist/strix --version + fi + + if [[ "${{ matrix.target }}" == "linux-arm64" ]]; then + file dist/strix + file dist/strix | grep -q "ARM aarch64" || { + echo "::error::linux-arm64 artifact is not an ARM aarch64 binary" + exit 1 + } + fi + VERSION=$(grep '^version' pyproject.toml | head -1 | sed 's/.*"\(.*\)"/\1/') mkdir -p dist/release diff --git a/scripts/install.sh b/scripts/install.sh index 2024dd87..cacbd9cb 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -41,7 +41,7 @@ fi combo="$os-$arch" case "$combo" in - linux-x86_64|macos-x86_64|macos-arm64|windows-x86_64) + linux-x86_64|linux-arm64|macos-x86_64|macos-arm64|windows-x86_64) ;; *) echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}" diff --git a/strix/interface/update_check.py b/strix/interface/update_check.py index 56ed4cd5..71159267 100644 --- a/strix/interface/update_check.py +++ b/strix/interface/update_check.py @@ -271,7 +271,13 @@ def _release_target() -> str | None: if os_name is None: return None target = f"{os_name}-{arch}" - supported = {"linux-x86_64", "macos-x86_64", "macos-arm64", "windows-x86_64"} + supported = { + "linux-x86_64", + "linux-arm64", + "macos-x86_64", + "macos-arm64", + "windows-x86_64", + } return target if target in supported else None diff --git a/tests/test_install_script.py b/tests/test_install_script.py new file mode 100644 index 00000000..67ca4843 --- /dev/null +++ b/tests/test_install_script.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import stat +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + + +RELEASE_VERSION = "9.9.9" +RELEASE_TARGET = "linux-arm64" + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", + reason="scripts/install.sh is a POSIX shell installer", +) + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def _create_release_archive(tmp_path: Path) -> Path: + binary_name = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}" + binary_path = tmp_path / binary_name + _write_executable(binary_path, f"#!/bin/sh\nprintf 'strix {RELEASE_VERSION}\\n'\n") + + archive_path = tmp_path / f"{binary_name}.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(binary_path, arcname=binary_name) + return archive_path + + +def _create_mock_commands(tmp_path: Path, machine: str) -> Path: + mock_bin = tmp_path / "mock-bin" + mock_bin.mkdir() + _write_executable( + mock_bin / "uname", + f"""#!/bin/sh +case "$1" in + -s) echo Linux ;; + -m) echo {machine} ;; + *) echo "unexpected uname argument: $*" >&2; exit 1 ;; +esac +""", + ) + _write_executable(mock_bin / "docker", "#!/bin/sh\nexit 0\n") + _write_executable( + mock_bin / "curl", + """#!/bin/sh +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + output="$2" + shift 2 + continue + fi + printf '%s\\n' "$1" >> "$STRIX_TEST_CURL_LOG" + shift +done +cp "$STRIX_TEST_ARCHIVE" "$output" +""", + ) + return mock_bin + + +def _create_installer_environment( + tmp_path: Path, + archive_path: Path, + mock_bin: Path, +) -> tuple[dict[str, str], Path, Path]: + """Build the installer environment explicitly. + + Every variable the installer reads is listed here, so no inherited value + (`XDG_CONFIG_HOME`, `GITHUB_ACTIONS`, `TMPDIR`, ...) can send a write + outside the sandbox or change the code path under test. + """ + home_path = tmp_path / "home" + home_path.mkdir() + download_path = tmp_path / "downloads" + download_path.mkdir() + curl_log_path = tmp_path / "curl.log" + environment = { + "HOME": str(home_path), + "XDG_CONFIG_HOME": str(home_path / ".config"), + "PATH": f"{mock_bin}:/usr/bin:/bin", + "SHELL": "/bin/bash", + "TMPDIR": str(download_path), + "STRIX_TEST_ARCHIVE": str(archive_path), + "STRIX_TEST_CURL_LOG": str(curl_log_path), + "VERSION": RELEASE_VERSION, + } + return environment, home_path, curl_log_path + + +def _run_installer( + repository_root: Path, + environment: dict[str, str], +) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + ["/bin/bash", str(repository_root / "scripts/install.sh")], + cwd=repository_root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + + +def test_installer_downloads_and_runs_linux_arm64_release(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="aarch64") + environment, home_path, curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + + result = _run_installer(repository_root, environment) + + assert result.returncode == 0, result.stderr + expected_filename = f"strix-{RELEASE_VERSION}-{RELEASE_TARGET}.tar.gz" + assert expected_filename in curl_log_path.read_text(encoding="utf-8") + + installed_binary = home_path / ".strix/bin/strix" + installed_result = subprocess.run( # noqa: S603 + [str(installed_binary), "--version"], + capture_output=True, + text=True, + check=True, + ) + assert installed_result.stdout.strip() == f"strix {RELEASE_VERSION}" + + +def test_installer_rejects_unsupported_architecture(tmp_path: Path) -> None: + repository_root = Path(__file__).resolve().parents[1] + archive_path = _create_release_archive(tmp_path) + mock_bin = _create_mock_commands(tmp_path, machine="riscv64") + environment, home_path, curl_log_path = _create_installer_environment( + tmp_path, + archive_path, + mock_bin, + ) + + result = _run_installer(repository_root, environment) + + assert result.returncode != 0 + assert "Unsupported OS/Arch: linux/riscv64" in result.stdout + assert not curl_log_path.exists() + assert not (home_path / ".strix").exists() diff --git a/tests/test_update_check.py b/tests/test_update_check.py index b602f285..3583f393 100644 --- a/tests/test_update_check.py +++ b/tests/test_update_check.py @@ -159,14 +159,40 @@ def test_sha256_file(tmp_path: Path) -> None: assert update_check._sha256_file(path) == hashlib.sha256(b"strix").hexdigest() -def test_release_target(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Linux", "x86_64", "linux-x86_64"), + ("Linux", "aarch64", "linux-arm64"), + ("Linux", "arm64", "linux-arm64"), + ("Darwin", "arm64", "macos-arm64"), + ("Darwin", "riscv64", None), + ], +) +def test_release_target( + monkeypatch: pytest.MonkeyPatch, + system: str, + machine: str, + expected: str | None, +) -> None: + monkeypatch.setattr(platform, "system", lambda: system) + monkeypatch.setattr(platform, "machine", lambda: machine) + + assert update_check._release_target() == expected + + +def test_self_update_uses_linux_arm64_release(monkeypatch: pytest.MonkeyPatch) -> None: + requested_update: list[tuple[str, str]] = [] + + def record_download(version: str, target: str, _console: Console) -> bool: + requested_update.append((version, target)) + return True + + monkeypatch.setattr(update_check, "is_binary_install", lambda: True) + monkeypatch.setattr(update_check, "get_version", lambda: "1.0.0") monkeypatch.setattr(platform, "system", lambda: "Linux") - monkeypatch.setattr(platform, "machine", lambda: "x86_64") - assert update_check._release_target() == "linux-x86_64" + monkeypatch.setattr(platform, "machine", lambda: "aarch64") + monkeypatch.setattr(update_check, "_download_and_replace", record_download) - monkeypatch.setattr(platform, "system", lambda: "Darwin") - monkeypatch.setattr(platform, "machine", lambda: "arm64") - assert update_check._release_target() == "macos-arm64" - - monkeypatch.setattr(platform, "machine", lambda: "riscv64") - assert update_check._release_target() is None + assert update_check.self_update(Console(file=io.StringIO()), version="1.1.0") is True + assert requested_update == [("1.1.0", "linux-arm64")]