@@ -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
9595def 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+
150251def 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