Skip to content

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

Merged
mnriem merged 2 commits into
github:mainfrom
lselvar:fix/private-github-release-asset-downloads
Jun 2, 2026
Merged

fix: resolve GitHub release asset API URL for private repo extension downloads#2792
mnriem merged 2 commits into
github:mainfrom
lselvar:fix/private-github-release-asset-downloads

Conversation

@lselvar
Copy link
Copy Markdown
Contributor

@lselvar lselvar commented Jun 1, 2026

Summary

  • Fixes specify extension add when the catalog download_url points at a GitHub release URL for a private or SSO-protected repository
  • For private/SSO repos, browser release URLs (https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip) redirect to an HTML/SSO page instead of the ZIP asset, causing installation to fail with BadZipFile: File is not a zip file

Fix — two download URL shapes are now handled

Browser release URL (commit 1):

  • Detects GitHub browser release asset URLs during download
  • Resolves the matching asset via the GitHub REST API: GET /repos/<owner>/<repo>/releases/tags/<tag>
  • Downloads the matched asset URL with Accept: application/octet-stream
  • Falls back to the original download URL if the API call fails

Direct REST asset URL (commit 2):

  • When download_url is already a GitHub REST release asset URL (https://api.github.com/repos/<owner>/<repo>/releases/assets/<id>), skips the metadata lookup and downloads directly with Accept: application/octet-stream
  • That header is required because GitHub returns release asset metadata JSON by default; Accept: application/octet-stream returns the ZIP payload

Other URLs: existing downloader behavior is unchanged.

Auth is preserved end-to-end through the existing specify_cli.authentication.http.open_url infrastructure.

Test plan

  • Run focused test suite to confirm new and existing behavior:
    UV_NATIVE_TLS=true SSL_CERT_FILE=/opt/homebrew/etc/openssl@3/cert.pem UV_DEFAULT_INDEX=https://pypi.org/simple PYTHONPATH=src uv run --extra test pytest tests/test_extensions.py -k 'make_request or fetch_single_catalog_sends_auth_header or download_extension_sends_auth_header or download_extension_accepts_direct_github_rest_asset_url'
    Expected: 13 passed, 190 deselected
  • Verify specify extension add against an extension hosted on a private GitHub repo using a browser release URL — asset should be resolved via the API and downloaded correctly
  • Verify specify extension add against a catalog using a direct REST asset URL (api.github.com/repos/.../releases/assets/<id>) — should download directly with auth and Accept: application/octet-stream
  • Verify specify extension add still works for public GitHub release URLs and non-GitHub URLs (fallback paths not triggered)

AI disclosure: This PR was developed with Claude Code assistance.

🤖 Generated with Claude Code

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>
@lselvar lselvar requested a review from mnriem as a code owner June 1, 2026 15:16
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented Jun 1, 2026

Just curious, can it refer to the REST URL directly?

…oads

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>
@lselvar
Copy link
Copy Markdown
Contributor Author

lselvar commented Jun 1, 2026

Just curious, can it refer to the REST URL directly?

Yes, it can refer to the REST asset URL directly, but the downloader still needs to send Accept: application/octet-stream; otherwise GitHub returns the asset metadata JSON rather than the ZIP, which leads to the same BadZipFile failure.

I used the browser release URL as the input because catalogs commonly get/store GitHub’s browser_download_url, which is easier to author and stable by tag + asset name. The REST asset URL requires the numeric asset id.

That said, I think it makes sense to support both. Have updated the PR so if download_url is already a GitHub REST asset URL like:

[https://api.github.com/repos/<owner>/<repo>/releases/assets/<asset_id>](https://api.github.com/repos/%3Cowner%3E/%3Crepo%3E/releases/assets/%3Casset_id%3E%60)

we skip the lookup and just download it with Accept: application/octet-stream. The browser URL path would continue to resolve to that REST URL internally.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes specify extension add for extensions whose catalog download_url points to GitHub release assets in private or SSO-protected repositories by ensuring the downloader retrieves the actual ZIP payload (rather than an HTML redirect/metadata response) via the GitHub REST API.

Changes:

  • Adds logic to detect GitHub release “browser” URLs and resolve them to the corresponding GitHub REST release asset API URL before downloading.
  • Supports direct GitHub REST release asset URLs by downloading them with Accept: application/octet-stream to obtain the binary ZIP content.
  • Expands extension download tests to cover both URL shapes and verify auth + Accept header behavior.
Show a summary per file
File Description
src/specify_cli/extensions.py Resolves GitHub release asset URLs to API asset URLs and adds Accept: application/octet-stream for correct binary downloads.
tests/test_extensions.py Adds/updates tests to validate the new GitHub release-asset download behavior (browser URL resolution + direct REST asset URL).

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 0

@mnriem mnriem merged commit c9c02ae into github:main Jun 2, 2026
11 checks passed
@mnriem
Copy link
Copy Markdown
Collaborator

mnriem commented Jun 2, 2026

Thank you!

mnriem added a commit that referenced this pull request Jun 5, 2026
… workflow downloads (#2855)

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

- Add shared `resolve_github_release_asset_api_url` utility to `_github_http.py` for
  reuse across preset and workflow download paths
- Apply the same private-repo fix from PR #2792 (extensions) to:
  - `PresetCatalog.download_pack` — ZIP downloads via catalog `download_url`
  - `preset add --from <url>` — ZIP downloads from a direct URL
  - `workflow add <url>` — workflow YAML downloads from a direct URL
  - `workflow add <id>` (catalog) — workflow YAML downloads via catalog `url`
- For browser release URLs (`github.com/…/releases/download/…`), the asset is
  resolved via the GitHub REST API and downloaded with `Accept: application/octet-stream`
- Direct REST API asset URLs (`api.github.com/…/releases/assets/<id>`) are
  downloaded directly with `Accept: application/octet-stream`
- Auth is preserved end-to-end through the existing `open_url` infrastructure
- Update `test_download_pack_sends_auth_header` and add
  `test_download_pack_accepts_direct_github_rest_asset_url` to cover both paths

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

* fix: URL-encode tag in release API URL to handle special characters

Encode the tag as a path segment (using quote with safe='') when
building the releases/tags/<tag> API URL. This prevents malformed
URLs when tags contain reserved characters like '/' or '#'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add CLI-level tests for preset add --from GitHub release URL resolution

Adds regression tests covering:
- resolve_github_release_asset_api_url unit tests (passthrough, resolution,
  network error, URL encoding of special chars in tags)
- CLI-level 'preset add --from <github-release-url>' end-to-end flow
- CLI-level 'preset add --from <api-asset-url>' direct passthrough

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor: deduplicate release URL resolution; fix test issues

- ExtensionCatalog._resolve_github_release_asset_api_url now delegates
  to the shared helper in _github_http.py (also gains URL-encoding fix)
- Remove unused 'io' import from test_github_http.py
- Remove duplicate 'provides' dict keys accidentally added to test_presets.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: align resolver timeout with download timeout; add workflow CLI tests

- Pass timeout=30 to resolve_github_release_asset_api_url in both
  workflow add paths so worst-case latency matches the download timeout
- Add CLI-level regression tests for 'workflow add <url>' covering
  browser URL resolution and direct API asset URL passthrough

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: remove unused urllib.request import; add catalog workflow test

- Remove unused 'import urllib.request' in preset add --from path
- Add CLI test for catalog-based 'workflow add <id>' with GitHub
  release URL resolution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: remove unused MagicMock imports from tests

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants