fix: resolve GitHub release asset API URL for private repo extension downloads (#2792)

* fix: resolve GitHub release asset API URL for private repo downloads

For private or SSO-protected GitHub repos, browser release download URLs
redirect to HTML/SSO instead of the ZIP asset. This commit resolves the
asset via the GitHub REST API and downloads with Accept: application/octet-stream,
falling back to the original URL if the API call fails.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: support direct GitHub REST release asset URLs in extension downloads

When a catalog download_url is already a GitHub REST release asset URL
(https://api.github.com/repos/<owner>/<repo>/releases/assets/<id>),
skip the release metadata lookup and download directly with
Accept: application/octet-stream. This complements the browser URL
resolution from the previous commit, covering catalogs that reference
the REST API directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lselvar
2026-06-02 18:15:36 -04:00
committed by GitHub
parent d79a514b30
commit c9c02ae790
2 changed files with 128 additions and 11 deletions

View File

@@ -1749,13 +1749,59 @@ class ExtensionCatalog(CatalogStackBase):
from specify_cli.authentication.http import build_request
return build_request(url)
def _open_url(self, url: str, timeout: int = 10):
def _open_url(
self,
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
"""
from specify_cli.authentication.http import open_url
return open_url(url, timeout)
return open_url(url, timeout, extra_headers=extra_headers)
def _resolve_github_release_asset_api_url(
self,
download_url: str,
timeout: int = 60,
) -> Optional[str]:
"""Resolve a GitHub release asset URL to its API asset URL."""
import urllib.error
from urllib.parse import unquote, urlparse
parsed = urlparse(download_url)
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
if (
parsed.hostname == "api.github.com"
and len(parts) >= 6
and parts[:1] == ["repos"]
and parts[3:5] == ["releases", "assets"]
):
return download_url
if parsed.hostname != "github.com":
return None
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
owner, repo, tag = parts[0], parts[1], parts[4]
asset_name = "/".join(parts[5:])
release_url = f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{tag}"
try:
with self._open_url(release_url, timeout=timeout) as response:
release_data = json.loads(response.read())
except (urllib.error.URLError, json.JSONDecodeError):
return None
for asset in release_data.get("assets", []):
if asset.get("name") == asset_name and asset.get("url"):
return str(asset["url"])
return None
def get_active_catalogs(self) -> List[CatalogEntry]:
"""Get the ordered list of active catalogs.
@@ -2155,9 +2201,15 @@ class ExtensionCatalog(CatalogStackBase):
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
if resolved_download_url:
download_url = resolved_download_url
extra_headers = {"Accept": "application/octet-stream"}
# Download the ZIP file
try:
with self._open_url(download_url, timeout=60) as response:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)

View File

@@ -2804,17 +2804,33 @@ class TestExtensionCatalog:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
release_response = MagicMock()
release_response.read.return_value = json.dumps(
{
"assets": [
{
"name": "test-ext.zip",
"url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
]
}
).encode()
release_response.__enter__ = lambda s: s
release_response.__exit__ = MagicMock(return_value=False)
captured = {}
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured["req"] = req
return mock_response
captured.append(req)
if req.full_url.endswith("/releases/tags/v1"):
return release_response
return asset_response
mock_opener.open.side_effect = fake_open
@@ -2829,7 +2845,56 @@ class TestExtensionCatalog:
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert captured["req"].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/tags/v1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
import zipfile
import io
monkeypatch.setenv("GITHUB_TOKEN", "ghp_testtoken")
self._inject_github_config(monkeypatch, token_env="GITHUB_TOKEN")
catalog = self._make_catalog(temp_dir)
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
asset_response = MagicMock()
asset_response.read.return_value = zip_bytes
asset_response.__enter__ = lambda s: s
asset_response.__exit__ = MagicMock(return_value=False)
captured = []
mock_opener = MagicMock()
def fake_open(req, timeout=None):
captured.append(req)
return asset_response
mock_opener.open.side_effect = fake_open
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://api.github.com/repos/org/repo/releases/assets/1",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
catalog.download_extension("test-ext", target_dir=temp_dir)
assert len(captured) == 1
assert captured[0].full_url == "https://api.github.com/repos/org/repo/releases/assets/1"
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[0].get_header("Accept") == "application/octet-stream"