From 00e69ca2fcabd1da3bef858a99a8eb7b4a900cdd Mon Sep 17 00:00:00 2001 From: Dmitry Meyer Date: Wed, 16 Sep 2026 11:46:29 +0000 Subject: [PATCH] Treat pre-releases as final releases in feature gates `_parse_version` returned `None` for any pre-release or dev build, which `_check_min_version` interprets as "latest", opening every feature gate. Since we publish rc builds (e.g. `0.21.4rc1`), a shim or runner on an rc was assumed to support features it does not have. For example, a shim on `0.20.25rc1` passed the `(0, 21, 3)` gate for restart-safe RUNNING tasks, letting the server restart a shim with running jobs on it. Ignore the pre-release, dev, post-release, and local segments instead, so `0.20.1rc1` is treated as `0.20.1`. An rc is cut from the release branch, so it normally carries the features of the version it leads to. Bare run numbers of staging builds (e.g. `1234`) still resolve to "latest". --- .../server/services/runner/client.py | 15 ++++++------- .../server/services/runner/test_client.py | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/dstack/_internal/server/services/runner/client.py b/src/dstack/_internal/server/services/runner/client.py index 8ade87d87..3407c7d8e 100644 --- a/src/dstack/_internal/server/services/runner/client.py +++ b/src/dstack/_internal/server/services/runner/client.py @@ -855,20 +855,17 @@ def _is_json_response(response: requests.Response) -> bool: def _parse_version(version_string: str) -> Optional[_Version]: """ - Returns a (major, minor, micro) tuple if the version if final. - Returns `None`, which means "latest", if: - * the version is prerelease or dev build -- assuming that in most cases it's a build based on - the latest final release - * the version consists of only major part or not valid at all, e.g., staging builds have - GitHub run number (e.g., 1234) instead of the version -- assuming that it's a "bleeding edge", - not yet released version + Returns a (major, minor, micro) tuple for feature gating. The pre-release, dev, post-release, + and local segments are ignored, that is, `0.20.1rc1` is treated as `0.20.1` -- assuming that + a build carrying a version has the features released in that version. + Returns `None`, which means "latest", if the version consists of only major part or not valid + at all, e.g., staging builds have GitHub run number (e.g., 1234) instead of the version + -- assuming that it's a "bleeding edge", not yet released version. """ try: version = packaging.version.parse(version_string) except packaging.version.InvalidVersion: return None - if version.is_prerelease or version.is_devrelease: - return None release = version.release if len(release) <= 1: return None diff --git a/src/tests/_internal/server/services/runner/test_client.py b/src/tests/_internal/server/services/runner/test_client.py index ad253fbb2..dddd946c2 100644 --- a/src/tests/_internal/server/services/runner/test_client.py +++ b/src/tests/_internal/server/services/runner/test_client.py @@ -241,10 +241,9 @@ class TestShimClientNegotiate(BaseShimClientTest): # invalid versions, assuming local builds with the latest version pytest.param(None, 2, marks=pytest.mark.shim_version("latest")), pytest.param(None, 2, marks=pytest.mark.shim_version("0.17.0-next")), - # even though this version is less than _FUTURE_API_MIN_VERSION, for the sake of - # simplicity we assume that any non-final version is the latest; normally, users - # should not use non-latest RC versions - pytest.param(None, 2, marks=pytest.mark.shim_version("0.17.0rc1")), + # pre-release versions are treated as the final version they lead to + pytest.param((0, 17, 0), 1, marks=pytest.mark.shim_version("0.17.0rc1")), + pytest.param((0, 18, 34), 2, marks=pytest.mark.shim_version("0.18.34rc1")), ], ) def test( @@ -687,9 +686,17 @@ class TestParseVersion: def test_valid_final(self, value: str, expected: tuple[int, int, int]): assert _parse_version(value) == expected - @pytest.mark.parametrize("value", ["1.12alpha1", "1.12.3rc1", "1.12.3.dev0"]) - def test_valid_pre_dev_local(self, value: str): - assert _parse_version(value) is None + @pytest.mark.parametrize( + ["value", "expected"], + [ + ["1.12alpha1", (1, 12, 0)], + ["1.12.3rc1", (1, 12, 3)], + ["1.12.3.dev0", (1, 12, 3)], + ["1.12.3.post1", (1, 12, 3)], + ], + ) + def test_valid_pre_dev_post(self, value: str, expected: tuple[int, int, int]): + assert _parse_version(value) == expected @pytest.mark.parametrize("value", ["1", "1234"]) def test_valid_major_only(self, value: str):