From 56e70b8b984107360281c69a7a1ba49ec20d7cb0 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 May 2026 02:34:25 -0500 Subject: [PATCH 1/5] fix(gltest): pin GenVM version for direct runner via GENVM_VERSION The direct runner resolved GitHub's bare /releases/latest and always downloaded genvm-universal.tar.xz. GenVM v0.3.0-rc0 shipped as a non-prerelease and dropped that asset, so consumers (gltest direct mode and GLSim, which share this loader) broke with HTTP 404. - get_latest_version() now queries the releases API and returns the newest release that is not a pre-release and still ships genvm-universal.tar.xz, instead of trusting GitHub's "latest" pointer. - resolve_version() adds a GENVM_VERSION env var so callers can pin a deterministic version regardless of what GitHub marks as latest. --- gltest/direct/sdk_loader.py | 57 ++++++++++++++--- tests/gltest_direct/test_sdk_loader.py | 88 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 tests/gltest_direct/test_sdk_loader.py diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index 9833551..587ab78 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -17,6 +17,16 @@ CACHE_DIR = Path.home() / ".cache" / "gltest-direct" GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases" +GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases" + +# The runner bundle the direct loader knows how to consume. +UNIVERSAL_ASSET = "genvm-universal.tar.xz" +# Pins the GenVM release instead of tracking GitHub's "latest" — lets CI stay +# deterministic across GenVM releases that may restructure or drop assets. +GENVM_VERSION_ENV = "GENVM_VERSION" +# Used only when the GitHub API is unreachable; last release shipping the +# universal tarball as of writing. +FALLBACK_VERSION = "v0.2.16" RUNNER_TYPE = "py-genlayer" STD_LIB_TYPE = "py-lib-genlayer-std" @@ -43,19 +53,47 @@ def parse_contract_header(contract_path: Path) -> Dict[str, str]: def get_latest_version() -> str: - """Get latest genvm release version from GitHub.""" + """Newest GenVM release that still ships the universal tarball. + + Skips pre-releases and releases without ``genvm-universal.tar.xz``: the + 0.3.0+ line restructured its release assets, so GitHub's bare "latest" can + point at a release the direct runner cannot consume. + """ try: req = urllib.request.Request( - f"{GITHUB_RELEASES_URL}/latest", - method="HEAD", + f"{GITHUB_API_RELEASES}?per_page=100", + headers={ + "User-Agent": "gltest-direct", + "Accept": "application/vnd.github+json", + }, ) - req.add_header("User-Agent", "gltest-direct") with urllib.request.urlopen(req, timeout=10) as resp: - final_url = resp.url - version = final_url.split("/")[-1] - return version + releases = json.loads(resp.read().decode("utf-8")) + for release in releases: # GitHub returns releases newest-first + if release.get("prerelease") or release.get("draft"): + continue + assets = release.get("assets", []) + if any(asset.get("name") == UNIVERSAL_ASSET for asset in assets): + return release["tag_name"] except Exception: - return "v0.2.12" + pass + return FALLBACK_VERSION + + +def resolve_version() -> str: + """Resolve which GenVM version the direct runner should use. + + Priority: ``GENVM_VERSION`` env var > newest cached version > latest + release. The env var lets callers pin a deterministic version instead of + tracking whatever GitHub currently marks as the latest release. + """ + pinned = os.environ.get(GENVM_VERSION_ENV) + if pinned: + return pinned + cached = list_cached_versions() + if cached: + return cached[0] + return get_latest_version() def list_cached_versions() -> List[str]: @@ -208,8 +246,7 @@ def setup_sdk_paths( contract_deps = parse_contract_header(contract_path) if version is None: - cached = list_cached_versions() - version = cached[0] if cached else get_latest_version() + version = resolve_version() tarball = download_artifacts(version) diff --git a/tests/gltest_direct/test_sdk_loader.py b/tests/gltest_direct/test_sdk_loader.py new file mode 100644 index 0000000..431d20f --- /dev/null +++ b/tests/gltest_direct/test_sdk_loader.py @@ -0,0 +1,88 @@ +"""Unit tests for direct-runner GenVM version resolution.""" + +import json + +from gltest.direct import sdk_loader + + +class _FakeResponse: + """Minimal context-manager stand-in for urllib's HTTP response.""" + + def __init__(self, payload): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + +class TestResolveVersion: + """resolve_version() precedence: env var > cache > latest release.""" + + def test_env_var_takes_precedence(self, monkeypatch): + monkeypatch.setenv(sdk_loader.GENVM_VERSION_ENV, "v1.2.3") + monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"]) + monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + + assert sdk_loader.resolve_version() == "v1.2.3" + + def test_falls_back_to_newest_cached_version(self, monkeypatch): + monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) + monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: ["v0.2.16"]) + monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + + assert sdk_loader.resolve_version() == "v0.2.16" + + def test_falls_back_to_latest_when_no_cache(self, monkeypatch): + monkeypatch.delenv(sdk_loader.GENVM_VERSION_ENV, raising=False) + monkeypatch.setattr(sdk_loader, "list_cached_versions", lambda: []) + monkeypatch.setattr(sdk_loader, "get_latest_version", lambda: "v0.9.9") + + assert sdk_loader.resolve_version() == "v0.9.9" + + +class TestGetLatestVersion: + """get_latest_version() skips pre-releases and assetless releases.""" + + def test_skips_releases_without_universal_asset(self, monkeypatch): + releases = [ + {"tag_name": "v0.3.0-rc0", "prerelease": False, "assets": [ + {"name": "genvm-linux-amd64.tar.xz"}, + ]}, + {"tag_name": "v0.2.16", "prerelease": False, "assets": [ + {"name": sdk_loader.UNIVERSAL_ASSET}, + ]}, + ] + monkeypatch.setattr( + "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) + ) + + assert sdk_loader.get_latest_version() == "v0.2.16" + + def test_skips_prereleases(self, monkeypatch): + releases = [ + {"tag_name": "v0.3.0-rc0", "prerelease": True, "assets": [ + {"name": sdk_loader.UNIVERSAL_ASSET}, + ]}, + {"tag_name": "v0.2.16", "prerelease": False, "assets": [ + {"name": sdk_loader.UNIVERSAL_ASSET}, + ]}, + ] + monkeypatch.setattr( + "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) + ) + + assert sdk_loader.get_latest_version() == "v0.2.16" + + def test_returns_fallback_when_api_unreachable(self, monkeypatch): + def _boom(*a, **k): + raise OSError("network down") + + monkeypatch.setattr("urllib.request.urlopen", _boom) + + assert sdk_loader.get_latest_version() == sdk_loader.FALLBACK_VERSION From 4574ce71d318c36c3569b88f7575188b8898bcca Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 May 2026 02:37:14 -0500 Subject: [PATCH 2/5] ci: run direct-runner unit tests The sdk_loader version-resolution tests live in tests/gltest_direct/, which CI did not run. Add a step for the fast unit tests only; the integration tests there download the GenVM SDK and stay out of CI. --- .github/workflows/tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 186af5f..ee3a6e4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,3 +31,8 @@ jobs: - name: Action | Run gltest tests run: uv run gltest tests/gltest/ + + # Direct-runner unit tests only; the integration tests in this folder + # download the GenVM SDK and are intentionally left out of CI. + - name: Action | Run direct runner unit tests + run: uv run gltest tests/gltest_direct/test_sdk_loader.py From 7f5d6fcf16c054e04d73cd8e19262d3f9feb7927 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 May 2026 02:56:01 -0500 Subject: [PATCH 3/5] fix(gltest): support renamed genvm-runners-all bundle asset GenVM 0.3.0 renamed the runner bundle from genvm-universal.tar.xz to genvm-runners-all.tar.xz. Both archives have the identical internal runners/{type}/{hash}.tar layout and the new bundle is a strict superset of the old runner hashes, so a contract that pins a runner hash resolves the same runner from either. download_artifacts() now tries each known bundle asset name and uses whichever the release publishes, normalizing the cache filename so the rest of the loader is unaffected. get_latest_version() accepts a release shipping either asset. This lets the direct runner work across the asset rename without freezing on a pre-0.3.0 release. --- gltest/direct/sdk_loader.py | 69 +++++++++++------ tests/gltest_direct/test_sdk_loader.py | 100 +++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 29 deletions(-) diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index 587ab78..6c80a2e 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -11,6 +11,7 @@ import json import tarfile import tempfile +import urllib.error import urllib.request from pathlib import Path from typing import Optional, Dict, List @@ -19,13 +20,14 @@ GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases" GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases" -# The runner bundle the direct loader knows how to consume. -UNIVERSAL_ASSET = "genvm-universal.tar.xz" +# Release asset names for the runner bundle, newest naming first. GenVM 0.3.0 +# renamed genvm-universal.tar.xz to genvm-runners-all.tar.xz; both archives have +# the same internal runners/{type}/{hash}.tar layout, so either works. +RUNNER_BUNDLE_ASSETS = ("genvm-runners-all.tar.xz", "genvm-universal.tar.xz") # Pins the GenVM release instead of tracking GitHub's "latest" — lets CI stay # deterministic across GenVM releases that may restructure or drop assets. GENVM_VERSION_ENV = "GENVM_VERSION" -# Used only when the GitHub API is unreachable; last release shipping the -# universal tarball as of writing. +# Used only when the GitHub API is unreachable. FALLBACK_VERSION = "v0.2.16" RUNNER_TYPE = "py-genlayer" @@ -53,11 +55,11 @@ def parse_contract_header(contract_path: Path) -> Dict[str, str]: def get_latest_version() -> str: - """Newest GenVM release that still ships the universal tarball. + """Newest GenVM release that ships a runner bundle the loader supports. - Skips pre-releases and releases without ``genvm-universal.tar.xz``: the - 0.3.0+ line restructured its release assets, so GitHub's bare "latest" can - point at a release the direct runner cannot consume. + Skips pre-releases and releases without a known runner-bundle asset, so + GitHub's bare "latest" cannot point at a release the direct runner is + unable to consume. """ try: req = urllib.request.Request( @@ -72,8 +74,8 @@ def get_latest_version() -> str: for release in releases: # GitHub returns releases newest-first if release.get("prerelease") or release.get("draft"): continue - assets = release.get("assets", []) - if any(asset.get("name") == UNIVERSAL_ASSET for asset in assets): + asset_names = {asset.get("name") for asset in release.get("assets", [])} + if asset_names.intersection(RUNNER_BUNDLE_ASSETS): return release["tag_name"] except Exception: pass @@ -109,17 +111,8 @@ def list_cached_versions() -> List[str]: return sorted(versions, reverse=True) -def download_artifacts(version: str) -> Path: - """Download genvm release tarball if not cached.""" - CACHE_DIR.mkdir(parents=True, exist_ok=True) - - tarball_name = f"genvm-universal-{version}.tar.xz" - tarball_path = CACHE_DIR / tarball_name - - if tarball_path.exists(): - return tarball_path - - url = f"{GITHUB_RELEASES_URL}/download/{version}/genvm-universal.tar.xz" +def _download_to(url: str, dest: Path) -> None: + """Stream ``url`` to ``dest``, replacing it atomically on success.""" print(f"Downloading {url}...") req = urllib.request.Request(url) @@ -143,8 +136,38 @@ def download_artifacts(version: str) -> Path: tmp_path = tmp.name print() - os.rename(tmp_path, tarball_path) - return tarball_path + os.rename(tmp_path, dest) + + +def download_artifacts(version: str) -> Path: + """Download the GenVM runner bundle for ``version`` if not cached. + + A release publishes the bundle under one of ``RUNNER_BUNDLE_ASSETS``; try + each name and use whichever exists. The cache filename is normalized so + the rest of the loader does not care which asset name was used. + """ + CACHE_DIR.mkdir(parents=True, exist_ok=True) + + tarball_path = CACHE_DIR / f"genvm-universal-{version}.tar.xz" + if tarball_path.exists(): + return tarball_path + + last_error: Optional[Exception] = None + for asset in RUNNER_BUNDLE_ASSETS: + url = f"{GITHUB_RELEASES_URL}/download/{version}/{asset}" + try: + _download_to(url, tarball_path) + return tarball_path + except urllib.error.HTTPError as e: + if e.code == 404: + last_error = e + continue + raise + + raise FileNotFoundError( + f"No GenVM runner bundle for {version}; tried " + f"{', '.join(RUNNER_BUNDLE_ASSETS)}" + ) from last_error def extract_runner( diff --git a/tests/gltest_direct/test_sdk_loader.py b/tests/gltest_direct/test_sdk_loader.py index 431d20f..0ac9caf 100644 --- a/tests/gltest_direct/test_sdk_loader.py +++ b/tests/gltest_direct/test_sdk_loader.py @@ -1,6 +1,7 @@ -"""Unit tests for direct-runner GenVM version resolution.""" +"""Unit tests for direct-runner GenVM version and artifact resolution.""" import json +import urllib.error from gltest.direct import sdk_loader @@ -49,13 +50,13 @@ def test_falls_back_to_latest_when_no_cache(self, monkeypatch): class TestGetLatestVersion: """get_latest_version() skips pre-releases and assetless releases.""" - def test_skips_releases_without_universal_asset(self, monkeypatch): + def test_skips_releases_without_a_bundle_asset(self, monkeypatch): releases = [ - {"tag_name": "v0.3.0-rc0", "prerelease": False, "assets": [ + {"tag_name": "v0.9.9", "prerelease": False, "assets": [ {"name": "genvm-linux-amd64.tar.xz"}, ]}, {"tag_name": "v0.2.16", "prerelease": False, "assets": [ - {"name": sdk_loader.UNIVERSAL_ASSET}, + {"name": "genvm-universal.tar.xz"}, ]}, ] monkeypatch.setattr( @@ -64,13 +65,28 @@ def test_skips_releases_without_universal_asset(self, monkeypatch): assert sdk_loader.get_latest_version() == "v0.2.16" + def test_accepts_renamed_runners_all_asset(self, monkeypatch): + releases = [ + {"tag_name": "v0.3.0", "prerelease": False, "assets": [ + {"name": "genvm-runners-all.tar.xz"}, + ]}, + {"tag_name": "v0.2.16", "prerelease": False, "assets": [ + {"name": "genvm-universal.tar.xz"}, + ]}, + ] + monkeypatch.setattr( + "urllib.request.urlopen", lambda *a, **k: _FakeResponse(releases) + ) + + assert sdk_loader.get_latest_version() == "v0.3.0" + def test_skips_prereleases(self, monkeypatch): releases = [ {"tag_name": "v0.3.0-rc0", "prerelease": True, "assets": [ - {"name": sdk_loader.UNIVERSAL_ASSET}, + {"name": "genvm-runners-all.tar.xz"}, ]}, {"tag_name": "v0.2.16", "prerelease": False, "assets": [ - {"name": sdk_loader.UNIVERSAL_ASSET}, + {"name": "genvm-universal.tar.xz"}, ]}, ] monkeypatch.setattr( @@ -86,3 +102,75 @@ def _boom(*a, **k): monkeypatch.setattr("urllib.request.urlopen", _boom) assert sdk_loader.get_latest_version() == sdk_loader.FALLBACK_VERSION + + +class TestDownloadArtifacts: + """download_artifacts() resolves whichever bundle asset a release ships.""" + + @staticmethod + def _http_404(url): + return urllib.error.HTTPError(url, 404, "Not Found", {}, None) + + def test_returns_cached_tarball_without_downloading(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + cached = tmp_path / "genvm-universal-v0.2.16.tar.xz" + cached.write_bytes(b"cached") + + def _fail(*a, **k): + raise AssertionError("should not download a cached tarball") + + monkeypatch.setattr(sdk_loader, "_download_to", _fail) + + assert sdk_loader.download_artifacts("v0.2.16") == cached + + def test_uses_first_available_asset(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + tried = [] + + def _download(url, dest): + tried.append(url) + dest.write_bytes(b"bundle") + + monkeypatch.setattr(sdk_loader, "_download_to", _download) + + result = sdk_loader.download_artifacts("v0.3.0") + + assert result == tmp_path / "genvm-universal-v0.3.0.tar.xz" + assert result.read_bytes() == b"bundle" + assert tried == [ + f"{sdk_loader.GITHUB_RELEASES_URL}/download/v0.3.0/genvm-runners-all.tar.xz" + ] + + def test_falls_back_to_old_asset_on_404(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + tried = [] + + def _download(url, dest): + tried.append(url) + if url.endswith("genvm-runners-all.tar.xz"): + raise self._http_404(url) + dest.write_bytes(b"bundle") + + monkeypatch.setattr(sdk_loader, "_download_to", _download) + + result = sdk_loader.download_artifacts("v0.2.16") + + assert result.read_bytes() == b"bundle" + assert [u.rsplit("/", 1)[-1] for u in tried] == list( + sdk_loader.RUNNER_BUNDLE_ASSETS + ) + + def test_raises_when_no_asset_found(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + + def _download(url, dest): + raise self._http_404(url) + + monkeypatch.setattr(sdk_loader, "_download_to", _download) + + try: + sdk_loader.download_artifacts("v9.9.9") + except FileNotFoundError as e: + assert "v9.9.9" in str(e) + else: + raise AssertionError("expected FileNotFoundError") From 9958b6b0a415b8fd15104adf0add8bc4dc73e9cd Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 May 2026 03:01:57 -0500 Subject: [PATCH 4/5] refactor(gltest): trim comments and docstrings in sdk_loader Reduce comment noise: collapse multi-line docstrings to one line and drop comments that just restate the code. --- .github/workflows/tests.yml | 3 +-- gltest/direct/sdk_loader.py | 28 ++++------------------------ 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee3a6e4..2522a8d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,7 +32,6 @@ jobs: - name: Action | Run gltest tests run: uv run gltest tests/gltest/ - # Direct-runner unit tests only; the integration tests in this folder - # download the GenVM SDK and are intentionally left out of CI. + # Unit tests only — integration tests here download the GenVM SDK. - name: Action | Run direct runner unit tests run: uv run gltest tests/gltest_direct/test_sdk_loader.py diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index 6c80a2e..2d7889f 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -20,14 +20,9 @@ GITHUB_RELEASES_URL = "https://github.com/genlayerlabs/genvm/releases" GITHUB_API_RELEASES = "https://api.github.com/repos/genlayerlabs/genvm/releases" -# Release asset names for the runner bundle, newest naming first. GenVM 0.3.0 -# renamed genvm-universal.tar.xz to genvm-runners-all.tar.xz; both archives have -# the same internal runners/{type}/{hash}.tar layout, so either works. +# GenVM 0.3.0 renamed this bundle from genvm-universal.tar.xz; newest name first. RUNNER_BUNDLE_ASSETS = ("genvm-runners-all.tar.xz", "genvm-universal.tar.xz") -# Pins the GenVM release instead of tracking GitHub's "latest" — lets CI stay -# deterministic across GenVM releases that may restructure or drop assets. GENVM_VERSION_ENV = "GENVM_VERSION" -# Used only when the GitHub API is unreachable. FALLBACK_VERSION = "v0.2.16" RUNNER_TYPE = "py-genlayer" @@ -55,12 +50,7 @@ def parse_contract_header(contract_path: Path) -> Dict[str, str]: def get_latest_version() -> str: - """Newest GenVM release that ships a runner bundle the loader supports. - - Skips pre-releases and releases without a known runner-bundle asset, so - GitHub's bare "latest" cannot point at a release the direct runner is - unable to consume. - """ + """Newest non-prerelease GenVM release that ships a known runner bundle.""" try: req = urllib.request.Request( f"{GITHUB_API_RELEASES}?per_page=100", @@ -83,12 +73,7 @@ def get_latest_version() -> str: def resolve_version() -> str: - """Resolve which GenVM version the direct runner should use. - - Priority: ``GENVM_VERSION`` env var > newest cached version > latest - release. The env var lets callers pin a deterministic version instead of - tracking whatever GitHub currently marks as the latest release. - """ + """GenVM version to use: GENVM_VERSION env var > newest cached > latest release.""" pinned = os.environ.get(GENVM_VERSION_ENV) if pinned: return pinned @@ -140,12 +125,7 @@ def _download_to(url: str, dest: Path) -> None: def download_artifacts(version: str) -> Path: - """Download the GenVM runner bundle for ``version`` if not cached. - - A release publishes the bundle under one of ``RUNNER_BUNDLE_ASSETS``; try - each name and use whichever exists. The cache filename is normalized so - the rest of the loader does not care which asset name was used. - """ + """Download the GenVM runner bundle for ``version`` if not cached.""" CACHE_DIR.mkdir(parents=True, exist_ok=True) tarball_path = CACHE_DIR / f"genvm-universal-{version}.tar.xz" From 1b97051a85b8a68136ce831a3bfd6bcf3cd7e560 Mon Sep 17 00:00:00 2001 From: Daniel Rojas Date: Thu, 21 May 2026 03:57:43 -0500 Subject: [PATCH 5/5] fix(gltest): order cached versions numerically and warn on resolve failure Address review feedback on PR #79: - list_cached_versions() sorted lexicographically, so cached[0] could pick an older release (e.g. v0.2.9 ahead of v0.2.16). Sort by numeric components instead. - get_latest_version() swallowed every exception silently; emit a stderr warning before falling back so resolution failures are visible. --- gltest/direct/sdk_loader.py | 17 +++++++++++++---- tests/gltest_direct/test_sdk_loader.py | 14 +++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/gltest/direct/sdk_loader.py b/gltest/direct/sdk_loader.py index 2d7889f..309ac6c 100644 --- a/gltest/direct/sdk_loader.py +++ b/gltest/direct/sdk_loader.py @@ -67,8 +67,12 @@ def get_latest_version() -> str: asset_names = {asset.get("name") for asset in release.get("assets", [])} if asset_names.intersection(RUNNER_BUNDLE_ASSETS): return release["tag_name"] - except Exception: - pass + except Exception as exc: + print( + f"Warning: could not resolve latest GenVM version ({exc}); " + f"falling back to {FALLBACK_VERSION}", + file=sys.stderr, + ) return FALLBACK_VERSION @@ -83,8 +87,13 @@ def resolve_version() -> str: return get_latest_version() +def _version_sort_key(version: str) -> tuple: + """Numeric-component sort key so v0.2.16 ranks above v0.2.9.""" + return tuple(int(n) for n in re.findall(r"\d+", version)) + + def list_cached_versions() -> List[str]: - """List all cached genvm versions.""" + """List all cached genvm versions, newest first.""" if not CACHE_DIR.exists(): return [] @@ -93,7 +102,7 @@ def list_cached_versions() -> List[str]: match = re.search(r"genvm-universal-(.+)\.tar\.xz", f.name) if match: versions.append(match.group(1)) - return sorted(versions, reverse=True) + return sorted(versions, key=_version_sort_key, reverse=True) def _download_to(url: str, dest: Path) -> None: diff --git a/tests/gltest_direct/test_sdk_loader.py b/tests/gltest_direct/test_sdk_loader.py index 0ac9caf..d33483b 100644 --- a/tests/gltest_direct/test_sdk_loader.py +++ b/tests/gltest_direct/test_sdk_loader.py @@ -95,13 +95,25 @@ def test_skips_prereleases(self, monkeypatch): assert sdk_loader.get_latest_version() == "v0.2.16" - def test_returns_fallback_when_api_unreachable(self, monkeypatch): + def test_returns_fallback_when_api_unreachable(self, monkeypatch, capsys): def _boom(*a, **k): raise OSError("network down") monkeypatch.setattr("urllib.request.urlopen", _boom) assert sdk_loader.get_latest_version() == sdk_loader.FALLBACK_VERSION + assert "could not resolve latest GenVM version" in capsys.readouterr().err + + +class TestListCachedVersions: + """list_cached_versions() orders versions numerically, newest first.""" + + def test_orders_versions_numerically(self, monkeypatch, tmp_path): + monkeypatch.setattr(sdk_loader, "CACHE_DIR", tmp_path) + for version in ("v0.2.9", "v0.2.16", "v0.10.0"): + (tmp_path / f"genvm-universal-{version}.tar.xz").write_bytes(b"") + + assert sdk_loader.list_cached_versions() == ["v0.10.0", "v0.2.16", "v0.2.9"] class TestDownloadArtifacts: