Files
voculinux/src/vocalinux/utils/update_checker.py
T
Jatin K MalikandGitHub e53fc2ceb0 fix(updates): follow the build channel and allow turning update checks off (#999)
Use the running build channel for background checks and add a preference to disable them.

Fixes #977
2026-10-21 00:26:09 -07:00

349 lines
13 KiB
Python

"""Check GitHub Releases for newer Vocalinux versions."""
import logging
import re
from dataclasses import dataclass
from typing import Optional
from urllib.parse import urlparse
from .. import version as _version_module
from ..version import __url__
from .proxy import requests_proxies
logger = logging.getLogger(__name__)
UPDATE_CHANNELS = ("stable", "nightly")
DEFAULT_UPDATE_CHANNEL = "stable"
DEFAULT_CHECK_AUTOMATICALLY = True
_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$")
_PRE_RELEASE_SUFFIX_RE = re.compile(
r"^(?P<main>\d+(?:\.\d+)*)(?:[-.](?P<pre>(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
re.IGNORECASE,
)
_NIGHTLY_TAG_RE = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$", re.IGNORECASE)
# Nightly CI embeds the build day as ``.devYYYYMMDD[+sha]`` (see release names).
_DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
@dataclass(frozen=True)
class ReleaseInfo:
"""Metadata for a GitHub release."""
tag_name: str
version: str
name: str
html_url: str
body: str
published_at: str
prerelease: bool
channel: str = DEFAULT_UPDATE_CHANNEL
def normalize_channel(channel: Optional[str]) -> str:
"""Return a supported update channel id."""
value = (channel or DEFAULT_UPDATE_CHANNEL).strip().casefold()
return value if value in UPDATE_CHANNELS else DEFAULT_UPDATE_CHANNEL
def _repo_parts(repo_url: str = __url__) -> tuple[str, str]:
match = _GITHUB_REPO_RE.match(repo_url.strip())
if not match:
raise ValueError(f"Unsupported repository URL: {repo_url}")
return match.group("owner"), match.group("repo")
def _repo_api_base(repo_url: str = __url__) -> str:
owner, repo = _repo_parts(repo_url)
return f"https://api.github.com/repos/{owner}/{repo}"
def normalize_version(version: str) -> str:
"""Strip a leading ``v`` and surrounding whitespace."""
return version.strip().lstrip("vV")
def _version_key(version: str) -> tuple:
"""Build a sortable key for PEP-440-like version strings."""
version = normalize_version(version)
if not version:
return (0, 0, 0, 0, "")
match = _PRE_RELEASE_SUFFIX_RE.match(version)
if match:
main = match.group("main")
prerelease = match.group("pre") or ""
else:
main, _, prerelease = version.partition("-")
# Drop build metadata (+flatpak) so it does not pollute comparison.
main = main.split("+", 1)[0]
prerelease = prerelease.split("+", 1)[0]
parts: list[int] = []
for part in main.split("."):
if part.isdigit():
parts.append(int(part))
elif part:
parts.append(0)
while len(parts) < 3:
parts.append(0)
# Stable releases outrank pre-releases with the same numeric prefix.
release_rank = 1 if not prerelease else 0
return tuple(parts) + (release_rank, prerelease.casefold())
def is_newer_version(current: str, latest: str) -> bool:
"""Return True when ``latest`` is newer than ``current`` (stable semver tags)."""
return _version_key(latest) > _version_key(current)
def _nightly_date(tag_or_version: str) -> Optional[str]:
"""Return YYYY-MM-DD from a nightly tag or ``.devYYYYMMDD`` version, or None."""
text = tag_or_version.strip()
match = _NIGHTLY_TAG_RE.match(text) or _NIGHTLY_TAG_RE.match(normalize_version(text))
if match:
return match.group(1)
# Installed nightlies report versions like ``0.14.2.dev20260802+27ecf88``.
dev_match = _DEV_DATE_RE.search(text)
if not dev_match:
return None
raw = dev_match.group(1)
return f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
def build_channel(version: Optional[str] = None) -> str:
"""Return the channel for this build; nightly builds carry .devYYYYMMDD from CI."""
if version is None:
version = _version_module.__version__
return "nightly" if _nightly_date(version) is not None else "stable"
def is_update_available(
current: str, latest: ReleaseInfo, channel: str = DEFAULT_UPDATE_CHANNEL
) -> bool:
"""Return whether ``latest`` is an update for ``current`` on the given channel."""
channel = normalize_channel(channel)
if channel == "nightly":
current_tag = current.strip()
if current_tag == latest.tag_name or normalize_version(current_tag) == latest.version:
return False
current_nightly = _nightly_date(current_tag)
latest_nightly = _nightly_date(latest.tag_name) or _nightly_date(latest.name)
if current_nightly and latest_nightly:
return latest_nightly > current_nightly
# Stable (or other) install following the nightly channel → offer latest nightly.
return True
return is_newer_version(current, latest.version)
# github.com/jatinkrmalik/vocalinux redirects here after the org transfer.
_VOCALINUX_GITHUB_OWNERS = frozenset({"VocaHQ", "jatinkrmalik"})
def _trusted_path_prefixes(repo_url: str = __url__) -> tuple[str, ...]:
"""Return GitHub path prefixes that belong to this project."""
owner, repo = _repo_parts(repo_url)
prefixes = [f"/{owner}/{repo}"]
known_owners = {name.casefold() for name in _VOCALINUX_GITHUB_OWNERS}
if repo.casefold() == "vocalinux" and owner.casefold() in known_owners:
prefixes.extend(f"/{name}/vocalinux" for name in sorted(_VOCALINUX_GITHUB_OWNERS))
seen: set[str] = set()
unique: list[str] = []
for prefix in prefixes:
key = prefix.casefold()
if key not in seen:
seen.add(key)
unique.append(prefix)
return tuple(unique)
def is_trusted_release_url(url: str, repo_url: str = __url__) -> bool:
"""Return True when ``url`` points at this project's GitHub pages."""
if not url:
return False
try:
parsed = urlparse(url)
prefixes = _trusted_path_prefixes(repo_url)
except ValueError:
return False
if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
return False
path = parsed.path.rstrip("/")
return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
def _release_from_payload(
data: dict, channel: str, repo_url: str = __url__
) -> Optional[ReleaseInfo]:
tag_name = data.get("tag_name") or ""
if not tag_name:
return None
html_url = data.get("html_url") or ""
if html_url and not is_trusted_release_url(html_url, repo_url):
logger.warning("Ignoring untrusted release URL: %s", html_url)
html_url = ""
return ReleaseInfo(
tag_name=str(tag_name),
version=normalize_version(str(tag_name)),
name=str(data.get("name") or tag_name),
html_url=html_url,
body=str(data.get("body") or ""),
published_at=str(data.get("published_at") or ""),
prerelease=bool(data.get("prerelease")),
channel=channel,
)
def _html_url_from_relative_update_url(update_url: object, repo_url: str) -> str:
"""Return a trusted tag URL from a relative GitHub ``update_url``, or empty."""
if not isinstance(update_url, str):
return ""
if not update_url.startswith("/") or update_url.startswith("//"):
return ""
if ".." in update_url or "\\" in update_url or "?" in update_url or "#" in update_url:
return ""
if ":" in update_url or any(ch.isspace() for ch in update_url):
return ""
candidate = "https://github.com" + update_url
if not is_trusted_release_url(candidate, repo_url):
return ""
return candidate
def _fetch_stable_from_releases_page(
requests, repo_url: str, timeout: float
) -> Optional[ReleaseInfo]:
"""Look up the latest stable tag from github.com when the REST API is blocked."""
owner, repo = _repo_parts(repo_url)
url = f"https://github.com/{owner}/{repo}/releases/latest"
headers = {
"Accept": "application/json",
"User-Agent": "Vocalinux-UpdateChecker",
}
try:
response = requests.get(url, headers=headers, timeout=timeout, proxies=requests_proxies())
final_url = getattr(response, "url", "") or ""
parsed = urlparse(final_url)
if (
parsed.scheme != "https"
or parsed.netloc.lower() != "github.com"
or not is_trusted_release_url(final_url, repo_url)
or response.status_code != 200
):
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
return None
data = response.json()
if not isinstance(data, dict) or not (data.get("tag_name") or ""):
logger.debug("Stable release HTML fallback unsuccessful for %s", url)
return None
html_url = _html_url_from_relative_update_url(data.get("update_url"), repo_url)
return _release_from_payload(
{"tag_name": data.get("tag_name"), "html_url": html_url},
"stable",
repo_url,
)
except (requests.exceptions.RequestException, ValueError, TypeError) as exc:
logger.debug("Stable release HTML fallback failed for %s: %s", url, exc)
return None
def fetch_latest_release(
repo_url: str = __url__,
timeout: float = 10.0,
channel: str = DEFAULT_UPDATE_CHANNEL,
) -> Optional[ReleaseInfo]:
"""Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
import requests
channel = normalize_channel(channel)
api_base = _repo_api_base(repo_url)
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "Vocalinux-UpdateChecker",
}
try:
if channel == "stable":
response = requests.get(
f"{api_base}/releases/latest",
headers=headers,
timeout=timeout,
proxies=requests_proxies(),
)
if response.status_code == 404:
logger.warning("No latest release found for %s", api_base)
return None
if response.status_code in (403, 429):
return _fetch_stable_from_releases_page(requests, repo_url, timeout)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
return None
return _release_from_payload(data, channel, repo_url)
# Page through releases so a burst of non-nightly tags cannot hide the
# newest nightly beyond the first response page.
max_pages = 10
for page in range(1, max_pages + 1):
response = requests.get(
f"{api_base}/releases",
headers=headers,
params={"per_page": 30, "page": page},
timeout=timeout,
proxies=requests_proxies(),
)
if response.status_code in (403, 429):
logger.debug("GitHub API rate-limited or forbidden for %s", api_base)
return None
response.raise_for_status()
releases = response.json()
if not isinstance(releases, list) or not releases:
break
for item in releases:
if not isinstance(item, dict) or item.get("draft"):
continue
tag_name = str(item.get("tag_name") or "")
if _NIGHTLY_TAG_RE.match(tag_name):
return _release_from_payload(item, channel, repo_url)
logger.warning("No nightly release found for %s", api_base)
return None
except (requests.exceptions.RequestException, ValueError) as exc:
logger.warning("Failed to fetch latest release: %s", exc)
return None
def format_release_notes(body: str) -> str:
"""Convert common GitHub-flavored markdown to readable plain text."""
if not body:
return "No release notes were published for this version."
text = body.replace("\r\n", "\n").replace("\r", "\n")
text = re.sub(r"<img\b[^>]*>", "", text, flags=re.IGNORECASE)
text = re.sub(r"</?details[^>]*>", "", text, flags=re.IGNORECASE)
text = re.sub(r"</?summary[^>]*>", "", text, flags=re.IGNORECASE)
# Keep fenced code contents (install commands) but drop the fences.
text = re.sub(r"```[^\n]*\n(.*?)```", r"\1", text, flags=re.DOTALL)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
text = re.sub(r"^#{1,6}\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
text = re.sub(r"[*_]{1,2}([^*_]+)[*_]{1,2}", r"\1", text)
# Flatten markdown tables into "Area: Description" lines.
text = re.sub(r"^\|[-:| ]+\|\s*$", "", text, flags=re.MULTILINE)
text = re.sub(
r"^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$",
r"• \1: \2",
text,
flags=re.MULTILINE,
)
text = re.sub(r"^---+\s*$", "", text, flags=re.MULTILINE)
text = re.sub(r"[ \t]+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()