Skip to content

Commit 8071c5c

Browse files
ignatovCopilot
andauthored
Fix nightly version updates when distribution sources drift (#236)
* Fix multi-source version resolution in update workflow * Format update workflow version resolution changes * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 9745362 commit 8071c5c

2 files changed

Lines changed: 254 additions & 47 deletions

File tree

‎.github/workflows/tests/test_update_versions.py‎

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import pytest
88

9-
from update_versions import check_agent_version, make_request
9+
from update_versions import check_agent_version, is_prerelease, make_request
1010

1111

1212
class TestMakeRequestServerErrors:
@@ -81,6 +81,13 @@ def test_429_raises(self, mock_urlopen):
8181
make_request("https://example.com/api")
8282

8383

84+
class TestPrereleaseDetection:
85+
"""Test filtering of non-stable package versions."""
86+
87+
def test_rc_suffix_is_treated_as_prerelease(self):
88+
assert is_prerelease("0.1.17rc0")
89+
90+
8491
class TestCheckAgentVersionNonGitHubRepo:
8592
"""Test that non-GitHub repository URLs are skipped for binary distributions."""
8693

@@ -140,10 +147,10 @@ def test_no_repo_binary_only_is_skipped(self):
140147
assert update is None
141148
assert error is None
142149

143-
@patch("update_versions.get_github_latest_release")
144-
def test_github_repo_binary_still_checked(self, mock_gh_release):
150+
@patch("update_versions.get_github_release_versions")
151+
def test_github_repo_binary_still_checked(self, mock_gh_release_versions):
145152
"""Binary agent with GitHub repository should still be checked."""
146-
mock_gh_release.return_value = ("2.0.0", ["asset.tar.gz"])
153+
mock_gh_release_versions.return_value = {"2.0.0"}
147154
agent_data = {
148155
"id": "some-agent",
149156
"version": "1.0.0",
@@ -161,4 +168,101 @@ def test_github_repo_binary_still_checked(self, mock_gh_release):
161168
assert error is None
162169
assert update is not None
163170
assert update.latest_version == "2.0.0"
164-
mock_gh_release.assert_called_once_with("https://github.com/owner/repo")
171+
mock_gh_release_versions.assert_called_once_with("https://github.com/owner/repo")
172+
173+
174+
class TestCheckAgentVersionMultiSourceResolution:
175+
"""Test version resolution when multiple distribution sources disagree."""
176+
177+
@patch("update_versions.get_npm_versions")
178+
@patch("update_versions.get_github_release_versions")
179+
def test_uses_latest_common_stable_version(self, mock_gh_release_versions, mock_npm_versions):
180+
"""Pick the highest version published on every distribution source."""
181+
mock_npm_versions.return_value = {"7.2.0", "7.2.1", "7.2.4"}
182+
mock_gh_release_versions.return_value = {"7.2.0", "7.2.1"}
183+
agent_data = {
184+
"id": "kilo",
185+
"version": "7.2.0",
186+
"repository": "https://github.com/owner/repo",
187+
"distribution": {
188+
"binary": {
189+
"darwin-aarch64": {
190+
"archive": "https://github.com/owner/repo/releases/download/v7.2.0/agent.tar.gz",
191+
"cmd": "./agent",
192+
}
193+
},
194+
"npx": {
195+
"package": "@owner/cli@7.2.0",
196+
"args": ["acp"],
197+
},
198+
},
199+
}
200+
201+
update, error = check_agent_version(Path("kilo/agent.json"), agent_data)
202+
203+
assert error is None
204+
assert update is not None
205+
assert update.latest_version == "7.2.1"
206+
207+
@patch("update_versions.get_npm_versions")
208+
@patch("update_versions.get_github_release_versions")
209+
def test_no_update_when_current_version_is_latest_common(
210+
self, mock_gh_release_versions, mock_npm_versions
211+
):
212+
"""Do not fail or update when sources disagree but the current version is shared."""
213+
mock_npm_versions.return_value = {"7.2.0", "7.2.1", "7.2.4"}
214+
mock_gh_release_versions.return_value = {"7.2.0", "7.2.1"}
215+
agent_data = {
216+
"id": "kilo",
217+
"version": "7.2.1",
218+
"repository": "https://github.com/owner/repo",
219+
"distribution": {
220+
"binary": {
221+
"darwin-aarch64": {
222+
"archive": "https://github.com/owner/repo/releases/download/v7.2.1/agent.tar.gz",
223+
"cmd": "./agent",
224+
}
225+
},
226+
"npx": {
227+
"package": "@owner/cli@7.2.1",
228+
"args": ["acp"],
229+
},
230+
},
231+
}
232+
233+
update, error = check_agent_version(Path("kilo/agent.json"), agent_data)
234+
235+
assert error is None
236+
assert update is None
237+
238+
@patch("update_versions.get_npm_versions")
239+
@patch("update_versions.get_github_release_versions")
240+
def test_returns_error_when_sources_have_no_common_version(
241+
self, mock_gh_release_versions, mock_npm_versions
242+
):
243+
"""Keep the mismatch as an error when there is no shared stable version."""
244+
mock_npm_versions.return_value = {"7.2.4"}
245+
mock_gh_release_versions.return_value = {"7.2.1"}
246+
agent_data = {
247+
"id": "kilo",
248+
"version": "7.2.0",
249+
"repository": "https://github.com/owner/repo",
250+
"distribution": {
251+
"binary": {
252+
"darwin-aarch64": {
253+
"archive": "https://github.com/owner/repo/releases/download/v7.2.0/agent.tar.gz",
254+
"cmd": "./agent",
255+
}
256+
},
257+
"npx": {
258+
"package": "@owner/cli@7.2.0",
259+
"args": ["acp"],
260+
},
261+
},
262+
}
263+
264+
update, error = check_agent_version(Path("kilo/agent.json"), agent_data)
265+
266+
assert update is None
267+
assert error is not None
268+
assert error.error == "Version mismatch across distributions: binary=7.2.1, npx=7.2.4"

‎.github/workflows/update_versions.py‎

Lines changed: 145 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def get_github_token() -> str | None:
6363
return os.environ.get("GITHUB_TOKEN")
6464

6565

66-
def make_request(url: str, headers: dict | None = None) -> dict | str | None:
66+
def make_request(url: str, headers: dict | None = None) -> dict | list | str | None:
6767
"""Make HTTP request and return JSON response."""
6868
req_headers = {"User-Agent": "ACP-Registry-Version-Checker/1.0"}
6969
if headers:
@@ -93,27 +93,98 @@ def make_request(url: str, headers: dict | None = None) -> dict | str | None:
9393

9494

9595
def is_prerelease(version: str) -> bool:
96-
"""Check if a version string is a pre-release (e.g., 1.0.0-beta.3)."""
97-
return bool(re.match(r"^\d+\.\d+\.\d+-.+", version))
96+
"""Check if a version string is not a stable numeric dotted release."""
97+
normalized = version.lstrip("v")
98+
return not bool(re.fullmatch(r"\d+(?:\.\d+)*", normalized))
9899

99100

100-
def get_npm_latest_version(package_name: str) -> str | None:
101-
"""Get latest version of an npm package."""
101+
def normalize_release_version(version: str | None) -> str | None:
102+
"""Normalize comparable release versions to a consistent dotted form."""
103+
if not version:
104+
return None
105+
if re.fullmatch(r"\d+", version):
106+
return f"{version}.0.0"
107+
if re.fullmatch(r"\d+\.\d+", version):
108+
return f"{version}.0"
109+
return version
110+
111+
112+
def version_sort_key(version: str) -> tuple[int, ...]:
113+
"""Return a sortable key for numeric dotted release versions."""
114+
normalized = normalize_release_version(version)
115+
if not normalized or not re.fullmatch(r"\d+(?:\.\d+)*", normalized):
116+
raise ValueError(f"Unsupported version format: {version}")
117+
return tuple(int(part) for part in normalized.split("."))
118+
119+
120+
def get_highest_stable_version(versions: set[str]) -> str | None:
121+
"""Return the highest non-prerelease version from a set."""
122+
stable_versions = {
123+
normalized
124+
for version in versions
125+
if not is_prerelease(version)
126+
for normalized in [normalize_release_version(version)]
127+
if normalized is not None
128+
}
129+
if not stable_versions:
130+
return None
131+
return max(stable_versions, key=version_sort_key)
132+
133+
134+
def get_npm_versions(package_name: str) -> set[str] | None:
135+
"""Get all stable published versions of an npm package."""
102136
# Handle scoped packages: @scope/name -> %40scope%2Fname
103137
encoded_name = package_name.replace("@", "%40").replace("/", "%2F")
104-
url = f"https://registry.npmjs.org/{encoded_name}/latest"
105-
data = make_request(url)
106-
if isinstance(data, dict) and "version" in data:
107-
return data["version"]
138+
url = f"https://registry.npmjs.org/{encoded_name}"
139+
data = make_request(
140+
url,
141+
headers={"Accept": "application/vnd.npm.install-v1+json"},
142+
)
143+
if isinstance(data, dict):
144+
versions = data.get("versions", {})
145+
if isinstance(versions, dict):
146+
stable_versions = {
147+
normalized
148+
for version in versions
149+
if not is_prerelease(version)
150+
for normalized in [normalize_release_version(version)]
151+
if normalized is not None
152+
}
153+
if stable_versions:
154+
return stable_versions
155+
156+
dist_tags = data.get("dist-tags", {})
157+
if isinstance(dist_tags, dict):
158+
latest = normalize_release_version(dist_tags.get("latest"))
159+
if latest and not is_prerelease(latest):
160+
return {latest}
108161
return None
109162

110163

111-
def get_pypi_latest_version(package_name: str) -> str | None:
112-
"""Get latest version of a PyPI package."""
164+
def get_pypi_versions(package_name: str) -> set[str] | None:
165+
"""Get all stable published versions of a PyPI package."""
113166
url = f"https://pypi.org/pypi/{package_name}/json"
114167
data = make_request(url)
115-
if isinstance(data, dict) and "info" in data:
116-
return data["info"].get("version")
168+
if isinstance(data, dict):
169+
releases = data.get("releases", {})
170+
if isinstance(releases, dict):
171+
stable_versions = set()
172+
for version, files in releases.items():
173+
if not files or is_prerelease(version):
174+
continue
175+
if all(isinstance(file, dict) and file.get("yanked", False) for file in files):
176+
continue
177+
normalized = normalize_release_version(version)
178+
if normalized:
179+
stable_versions.add(normalized)
180+
if stable_versions:
181+
return stable_versions
182+
183+
info = data.get("info", {})
184+
if isinstance(info, dict):
185+
latest = normalize_release_version(info.get("version"))
186+
if latest and not is_prerelease(latest):
187+
return {latest}
117188
return None
118189

119190

@@ -137,16 +208,46 @@ def get_github_latest_release(repo_url: str) -> tuple[str | None, list[str]]:
137208
if isinstance(data, dict):
138209
tag = data.get("tag_name", "")
139210
# Strip 'v' prefix if present
140-
version = tag.lstrip("v") if tag else None
141-
# Normalize to semver (x.y -> x.y.0)
142-
if version and re.match(r"^\d+\.\d+$", version):
143-
version = f"{version}.0"
211+
version = normalize_release_version(tag.lstrip("v") if tag else None)
144212
assets = [a["name"] for a in data.get("assets", [])]
145213
return version, assets
146214

147215
return None, []
148216

149217

218+
def get_github_release_versions(repo_url: str) -> set[str] | None:
219+
"""Get stable GitHub release versions published for a repository."""
220+
match = re.search(r"github\.com/([^/]+)/([^/]+)", repo_url)
221+
if not match:
222+
return None
223+
224+
owner, repo = match.groups()
225+
if repo.endswith(".git"):
226+
repo = repo[:-4]
227+
228+
api_url = f"https://api.github.com/repos/{owner}/{repo}/releases?per_page=100"
229+
data = make_request(api_url)
230+
if isinstance(data, list):
231+
versions = set()
232+
for release in data:
233+
if not isinstance(release, dict):
234+
continue
235+
if release.get("draft") or release.get("prerelease"):
236+
continue
237+
tag = release.get("tag_name", "")
238+
version = normalize_release_version(tag.lstrip("v") if tag else None)
239+
if version and not is_prerelease(version):
240+
versions.add(version)
241+
if versions:
242+
return versions
243+
244+
latest, _assets = get_github_latest_release(repo_url)
245+
if latest:
246+
return {latest}
247+
248+
return None
249+
250+
150251
def find_all_agents(registry_dir: Path) -> list[tuple[Path, dict]]:
151252
"""Find all agent.json files in the registry, excluding quarantined ones."""
152253
agents = []
@@ -199,56 +300,58 @@ def check_agent_version(
199300
distribution = agent_data.get("distribution", {})
200301
repository = agent_data.get("repository", "")
201302

202-
# Collect latest versions from all distribution sources
203-
source_versions: dict[str, tuple[str, str]] = {} # type -> (version, source_url)
303+
# Collect stable published versions from all distribution sources
304+
current_version = normalize_release_version(current_version) or current_version
305+
source_versions: dict[str, tuple[set[str], str]] = {} # type -> (versions, source_url)
204306

205307
if "npx" in distribution:
206308
package_spec = distribution["npx"].get("package", "")
207309
package_name = extract_npm_package_name(package_spec)
208310
if not package_name:
209311
return None, UpdateError(agent_id, "Could not extract npm package name")
210-
latest = get_npm_latest_version(package_name)
211-
if not latest:
212-
return None, UpdateError(agent_id, f"Could not fetch npm version for {package_name}")
213-
if is_prerelease(latest):
214-
return None, None # Skip pre-release versions
215-
source_versions["npx"] = (latest, f"https://registry.npmjs.org/{package_name}")
312+
versions = get_npm_versions(package_name)
313+
if not versions:
314+
return None, UpdateError(agent_id, f"Could not fetch npm versions for {package_name}")
315+
source_versions["npx"] = (versions, f"https://registry.npmjs.org/{package_name}")
216316

217317
if "uvx" in distribution:
218318
package_spec = distribution["uvx"].get("package", "")
219319
package_name = extract_pypi_package_name(package_spec)
220320
if not package_name:
221321
return None, UpdateError(agent_id, "Could not extract PyPI package name")
222-
latest = get_pypi_latest_version(package_name)
223-
if not latest:
224-
return None, UpdateError(agent_id, f"Could not fetch PyPI version for {package_name}")
225-
if is_prerelease(latest):
226-
return None, None # Skip pre-release versions
227-
source_versions["uvx"] = (latest, f"https://pypi.org/pypi/{package_name}/json")
322+
versions = get_pypi_versions(package_name)
323+
if not versions:
324+
return None, UpdateError(agent_id, f"Could not fetch PyPI versions for {package_name}")
325+
source_versions["uvx"] = (versions, f"https://pypi.org/pypi/{package_name}/json")
228326

229327
if "binary" in distribution and repository and "github.com" in repository:
230-
latest, _assets = get_github_latest_release(repository)
231-
if not latest:
328+
versions = get_github_release_versions(repository)
329+
if not versions:
232330
return None, UpdateError(
233331
agent_id,
234-
f"Could not fetch GitHub release for {repository}",
332+
f"Could not fetch GitHub releases for {repository}",
235333
)
236-
if is_prerelease(latest):
237-
return None, None # Skip pre-release versions
238-
source_versions["binary"] = (latest, repository)
334+
source_versions["binary"] = (versions, repository)
239335

240336
if not source_versions:
241337
if distribution:
242338
return None, None # Has distributions but none are checkable (e.g. binary without repo)
243339
return None, UpdateError(agent_id, "Unknown distribution type")
244340

245-
# Fail if sources disagree on the latest version
246-
unique_versions = {v for v, _ in source_versions.values()}
247-
if len(unique_versions) > 1:
248-
details = ", ".join(f"{t}={v}" for t, (v, _) in sorted(source_versions.items()))
341+
common_versions: set[str] | None = None
342+
for versions, _source_url in source_versions.values():
343+
common_versions = set(versions) if common_versions is None else common_versions & versions
344+
345+
if not common_versions:
346+
details = ", ".join(
347+
f"{dist_type}={get_highest_stable_version(versions) or 'none'}"
348+
for dist_type, (versions, _) in sorted(source_versions.items())
349+
)
249350
return None, UpdateError(agent_id, f"Version mismatch across distributions: {details}")
250351

251-
latest_version = unique_versions.pop()
352+
latest_version = get_highest_stable_version(common_versions)
353+
if not latest_version:
354+
return None, UpdateError(agent_id, "No stable versions found across distributions")
252355
if latest_version == current_version:
253356
return None, None # Up to date
254357

0 commit comments

Comments
 (0)