From 4fd688e673b003f15e8d48e9620b124708cfc92d Mon Sep 17 00:00:00 2001 From: beefcarver Date: Sun, 15 Nov 2026 09:51:01 -0500 Subject: [PATCH] fix(packaging): verify the release download against a CA bundle that exists python-build-standalone's OpenSSL has default trust paths that need not exist on the host, so urlopen failed with "unable to get local issuer certificate" while browsers and curl accepted the same chain. Fall back to certifi when OpenSSL's default cafile is missing, and name the bundle in use when verification still fails so the next occurrence is one line to diagnose. --- bump_packaging.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/bump_packaging.py b/bump_packaging.py index ce6890e..8d1e0c6 100644 --- a/bump_packaging.py +++ b/bump_packaging.py @@ -33,11 +33,14 @@ from __future__ import annotations import argparse import hashlib +import os import re +import ssl import subprocess import sys import tarfile import tempfile +import urllib.error import urllib.request from pathlib import Path @@ -193,6 +196,30 @@ def sha256(path: Path) -> str: return digest.hexdigest() +def ca_file() -> str | None: + """The CA bundle to verify with, or None to let OpenSSL use its own default. + + `uv python install` fetches a python-build-standalone interpreter whose + OpenSSL is compiled with default trust paths that need not exist on the + host (on macOS, and on distros that keep certs only in + /etc/ssl/certs/ca-certificates.crt, they often do not). Verification then + fails with `unable to get local issuer certificate` for every site, even + though curl and browsers are happy. Fall back to certifi, which `httpx` + already puts in the environment. + """ + if os.environ.get("SSL_CERT_FILE"): + return None # ssl honours the variable itself; do not second-guess it + paths = ssl.get_default_verify_paths() + for candidate in (paths.cafile, paths.openssl_cafile): + if candidate and Path(candidate).is_file(): + return None + try: + import certifi + except ModuleNotFoundError: + return None + return certifi.where() + + def fetch_asset(version: str, override: Path | None) -> Path: if override is not None: if not override.is_file(): @@ -200,10 +227,25 @@ def fetch_asset(version: str, override: Path | None) -> Path: return override url = RELEASE_ASSET.format(version=version, name=ASSET_NAME) say(f"downloading {url}") + cafile = ca_file() + context = ssl.create_default_context(cafile=cafile) target = Path(tempfile.mkdtemp()) / f"{ASSET_NAME}-{version}.tar.gz" try: - with urllib.request.urlopen(url) as response, target.open("wb") as handle: + with urllib.request.urlopen(url, context=context) as response, target.open( + "wb" + ) as handle: handle.write(response.read()) + except urllib.error.URLError as error: + # urlopen wraps socket/SSL failures; the cause is in `.reason`. + if isinstance(error.reason, ssl.SSLCertVerificationError): + bundle = cafile or ssl.get_default_verify_paths().cafile or "no bundle found" + raise fail( + f"cannot fetch the release asset ({error.reason.verify_message or error.reason}); " + f"verified against {bundle}. If that bundle is stale, point SSL_CERT_FILE " + "at a current one (on Arch and Debian that is " + "/etc/ssl/certs/ca-certificates.crt)" + ) from error + raise fail(f"cannot fetch the release asset ({error.reason}); is the tag published?") from error except OSError as error: raise fail(f"cannot fetch the release asset ({error}); is the tag published?") from error return target