Skip to content

Commit 43ecb49

Browse files
committed
Better support for tag-only releases
Signed-off-by: Matthew Ballance <matt.ballance@gmail.com>
1 parent 8547e6c commit 43ecb49

File tree

2 files changed

+100
-9
lines changed

2 files changed

+100
-9
lines changed

src/ivpm/pkg_types/package_gh_rls.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#*
2121
#****************************************************************************
2222
import os
23+
import fnmatch
2324
import httpx
2425
import json
2526
import re
@@ -89,20 +90,45 @@ def update(self, update_info):
8990
else:
9091
return self._update_normal(update_info, pkg_dir, file_url, forced_ext)
9192

93+
def _repo_base_url(self):
94+
"""Return the base GitHub API URL for this package's repo."""
95+
github_com_idx = self.url.find("github.com")
96+
return "https://api.github.com/repos/" + self.url[github_com_idx + len("github.com") + 1:]
97+
98+
def _fetch_tags(self):
99+
"""Fetch tags from GitHub API and normalize them to release-like dicts."""
100+
tags_url = self._repo_base_url() + "/tags"
101+
resp = httpx.get(tags_url, follow_redirects=True)
102+
if resp.status_code != 200:
103+
return []
104+
tags = json.loads(resp.content)
105+
# Normalize tags to look like release dicts so existing version-selection
106+
# logic can be reused. Tags only provide source archives, no binary assets.
107+
normalized = []
108+
for t in tags:
109+
normalized.append({
110+
"tag_name": t["name"],
111+
"prerelease": False,
112+
"assets": [],
113+
"tarball_url": t.get("tarball_url"),
114+
"zipball_url": t.get("zipball_url"),
115+
"_is_tag": True,
116+
})
117+
return normalized
118+
92119
def _resolve_release(self):
93120
"""Query GitHub API and resolve the release and asset to download.
94121
95122
Returns:
96123
Tuple of (rls_info, rls, file_url, forced_ext)
97124
"""
98-
github_com_idx = self.url.find("github.com")
99-
url = "https://api.github.com/repos/" + self.url[github_com_idx + len("github.com") + 1:] + "/releases"
100-
rls_info = httpx.get(url, follow_redirects=True)
125+
releases_url = self._repo_base_url() + "/releases"
126+
rls_info_resp = httpx.get(releases_url, follow_redirects=True)
101127

102-
if rls_info.status_code != 200:
103-
raise Exception("Failed to fetch release info: %d" % rls_info.status_code)
128+
if rls_info_resp.status_code != 200:
129+
raise Exception("Failed to fetch release info: %d" % rls_info_resp.status_code)
104130

105-
rls_info = json.loads(rls_info.content)
131+
rls_info = json.loads(rls_info_resp.content)
106132

107133
# Select release per version specification
108134
rls = None
@@ -113,19 +139,40 @@ def _resolve_release(self):
113139
rls = r
114140
break
115141
if rls is None:
116-
raise Exception("Failed to find latest release (prerelease=%s)" % self.prerelease)
142+
# No formal releases — fall back to most recent tag
143+
tags = self._fetch_tags()
144+
if tags:
145+
rls = tags[0]
146+
rls_info = tags
147+
else:
148+
raise Exception("Failed to find latest release (prerelease=%s)" % self.prerelease)
117149
else:
118150
rls = self._select_release_by_version(rls_info)
119151
if rls is None:
120-
raise Exception(f"No release matches version spec '{self.version}' (prerelease={self.prerelease})")
152+
# Not found in releases — try tags
153+
tags = self._fetch_tags()
154+
rls = self._select_release_by_version(tags)
155+
if rls is None:
156+
raise Exception(f"No release or tag matches version spec '{self.version}'")
157+
rls_info = tags
121158

122159
# Determine file to download
123160
file_url = None
124161
forced_ext = None
125162

126163
assets = rls.get("assets", [])
127164
if self.file is not None:
128-
raise NotImplementedError("File specification not yet supported")
165+
# Filter assets to those whose name starts with the basename or matches it as a pattern
166+
filtered = [
167+
a for a in assets
168+
if (lambda nm: nm.startswith(self.file) or fnmatch.fnmatch(nm, self.file))(
169+
a.get("name") or os.path.basename(a.get("browser_download_url", ""))
170+
)
171+
]
172+
if not filtered:
173+
names = [a.get("name") or os.path.basename(a.get("browser_download_url", "")) for a in assets]
174+
raise Exception(f"No assets matching basename '{self.file}' found in release. Available assets: {', '.join(names)}")
175+
assets = filtered
129176

130177
# If source=true, skip binary detection and go straight to source
131178
has_binaries = self._has_binary_assets(assets) if not self.source else False

test/unit/test_gh_rls.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,49 @@ def test_os_specific_packages(self):
190190
f"Expected OS-specific package error, got: {error_msg}"
191191
)
192192

193+
@unittest.skipUnless(_check_github_api_available(), "GitHub API rate-limited or unavailable")
194+
def test_tag_fallback_latest(self):
195+
"""Test that gh-rls falls back to tags when a project has no formal releases."""
196+
# OpenROAD-flow-scripts uses tags but not formal GitHub releases
197+
self.mkFile("ivpm.yaml", """
198+
package:
199+
name: gh_rls_tag_fallback
200+
dep-sets:
201+
- name: default-dev
202+
deps:
203+
- name: orfs
204+
url: https://github.com/The-OpenROAD-Project/OpenROAD-flow-scripts
205+
src: gh-rls
206+
version: latest
207+
source: true
208+
""")
209+
210+
self.ivpm_update(skip_venv=True)
211+
212+
pkg_dir = os.path.join(self.testdir, "packages", "orfs")
213+
self.assertTrue(os.path.isdir(pkg_dir), "orfs package directory missing")
214+
215+
@unittest.skipUnless(_check_github_api_available(), "GitHub API rate-limited or unavailable")
216+
def test_tag_fallback_exact_version(self):
217+
"""Test that gh-rls finds a specific tag when it is not a formal release."""
218+
self.mkFile("ivpm.yaml", """
219+
package:
220+
name: gh_rls_tag_exact
221+
dep-sets:
222+
- name: default-dev
223+
deps:
224+
- name: orfs
225+
url: https://github.com/The-OpenROAD-Project/OpenROAD-flow-scripts
226+
src: gh-rls
227+
version: 26Q1
228+
source: true
229+
""")
230+
231+
self.ivpm_update(skip_venv=True)
232+
233+
pkg_dir = os.path.join(self.testdir, "packages", "orfs")
234+
self.assertTrue(os.path.isdir(pkg_dir), "orfs package directory missing")
235+
236+
193237

194238

0 commit comments

Comments
 (0)