diff --git a/docs/UPDATE.md b/docs/UPDATE.md index 13d2b0a..cf310bc 100644 --- a/docs/UPDATE.md +++ b/docs/UPDATE.md @@ -4,7 +4,9 @@ How to upgrade an existing install, plus release notes for the current series. ## Quick update -Voculinux checks GitHub Releases in the background about every six hours (and shortly after startup). When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below. +Voculinux checks its own release server, the Gitea repository at `git.bongbetic.com/xavierk/voculinux`, in the background about every six hours (and shortly after startup). Upstream VocaHQ is never queried. When a newer build is on your selected channel, the tray menu gains an **Update Available...** entry and Settings -> About shows a green **New** badge; open that page for release notes and download links. Updates are not installed automatically. Re-run the installer (or your package manager) as below. + +A package-managed install (the Void `voculinux` package) makes no update check at all. Its updates arrive with `sudo xbps-install -Su`, and Settings -> About says so. ### Official installer diff --git a/src/vocalinux/ui/settings_about.py b/src/vocalinux/ui/settings_about.py index fa9b454..d1277f2 100644 --- a/src/vocalinux/ui/settings_about.py +++ b/src/vocalinux/ui/settings_about.py @@ -31,7 +31,7 @@ VOCAHQ_DISCORD_URL = "https://discord.gg/t6muquAJbm" VOCAHQ_X_URL = "https://x.com/vocahq" VOCAHQ_MAILTO_URL = "mailto:hello@vocahq.com" -# About page links that are not GitHub release URLs. +# About page links that are not release URLs. _ABOUT_OPEN_URLS = frozenset( { VOCULINUX_SITE_URL, @@ -169,7 +169,7 @@ _VOCAHQ_FAMILY_LINKS = ( def _can_open_url(url: str) -> bool: - """Return True for GitHub project URLs or the About page allowlist.""" + """Return True for this project's release URLs or the About page allowlist.""" return bool(url) and (url in _ABOUT_OPEN_URLS or is_trusted_release_url(url)) @@ -472,7 +472,7 @@ class AboutSectionMixin(Gtk.Dialog): self.check_updates_btn.get_style_context().add_class("suffix-button") _style_action_button(self.check_updates_btn, width=80) self.check_updates_btn.set_tooltip_text( - "Look up the latest release for this channel on GitHub" + "Look up the latest Voculinux release for this channel" ) self.check_updates_btn.connect("clicked", on_check_updates_clicked) diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 5ec4393..8596c52 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -70,6 +70,10 @@ from ..utils.model_choice import ( priority_for_size, size_for_priority, ) +from ..utils.packaging import ( # noqa: E402 + PACKAGED_UPDATE_HINT, + is_packaged_install, +) from ..utils.paths import models_dir # noqa: E402 from ..utils.proxy import ( # noqa: E402 DEFAULT_PROXY_MODE, @@ -6542,13 +6546,17 @@ class SettingsDialog(AboutSectionMixin): self._open_web_url(self._about_release_url) def _start_update_check(self): - """Kick off a background GitHub release lookup.""" + """Kick off a background release lookup, or explain packaged updates.""" channel = self._current_update_channel() + if is_packaged_install(): + # Packaged installs never query a release server (ticket #27). + self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT) + return self._update_check_generation += 1 generation = self._update_check_generation self.check_updates_btn.set_sensitive(False) self.check_updates_btn.set_label("…") - self.update_status_row.set_subtitle(f"Checking GitHub ({channel})…") + self.update_status_row.set_subtitle(f"Checking Voculinux releases ({channel})…") # Invalidate any in-flight result; that worker will restart on completion. if self._update_check_in_progress: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index d9a9535..0dbaf67 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -335,7 +335,7 @@ class TrayIndicator: self._model_keepalive.start() self._model_keepalive.bump() - # Background GitHub update checks (tray menu + optional notification) + # Background update checks (tray menu + optional notification) self._update_monitor = UpdateMonitor( get_enabled=self._update_checks_enabled, on_result=self._on_update_check_result, diff --git a/src/vocalinux/utils/packaging.py b/src/vocalinux/utils/packaging.py index 82704cd..21e191c 100644 --- a/src/vocalinux/utils/packaging.py +++ b/src/vocalinux/utils/packaging.py @@ -1,11 +1,10 @@ """Facts about installs managed by a system package manager. Ticket #27 (Void app template): the xbps template installs an empty -``usr/share/voculinux/packaged`` marker. When it exists, background -update checks stay available as a signal, but every user-visible update -affordance presents the package-manager path instead of release -downloads, so packaged users are never misdirected away from -package-manager updates. +``usr/share/voculinux/packaged`` marker. When it exists, the install never +queries a release server (``fetch_latest_release`` returns None without a +network request), and the update affordances say that updates arrive via +``xbps-install -Su``, never a release download. """ from pathlib import Path @@ -14,10 +13,12 @@ from typing import Optional #: Installed by the xbps template's post_install; absent everywhere else. PACKAGED_MARKER_PATH = Path("/usr/share/voculinux/packaged") +#: The Void command that delivers updates to a packaged install. +PACKAGED_UPDATE_COMMAND = "sudo xbps-install -Su" + #: Presented wherever a packaged install would otherwise offer a download. PACKAGED_UPDATE_HINT = ( - "Installed from a system package: update with your package manager " - "(on Void: sudo xbps-install -Syu voculinux)." + f"Installed from a system package: updates arrive via {PACKAGED_UPDATE_COMMAND}." ) @@ -38,7 +39,7 @@ def update_menu_tooltip(*, packaged: bool) -> str: if packaged: return ( "Open Settings → About for release notes; " - "update with your package manager (on Void: sudo xbps-install -Syu voculinux)" + f"updates arrive via {PACKAGED_UPDATE_COMMAND}" ) return "Open Settings → About for release notes and download links" diff --git a/src/vocalinux/utils/update_checker.py b/src/vocalinux/utils/update_checker.py index 38724f7..fb5d971 100644 --- a/src/vocalinux/utils/update_checker.py +++ b/src/vocalinux/utils/update_checker.py @@ -1,4 +1,9 @@ -"""Check GitHub Releases for newer Voculinux versions.""" +"""Check the Voculinux release server (Gitea) for newer versions. + +Voculinux is a fork: its releases live on the personal Gitea instance. The +upstream VocaHQ repository is never queried, and packaged installs never query +a release server at all (their updates arrive through xbps). +""" import logging import re @@ -8,7 +13,7 @@ from urllib.parse import urlparse from .. import version as _version_module from ..app_identity import UPDATE_CHECKER_USER_AGENT -from ..version import __url__ +from .packaging import is_packaged_install from .proxy import requests_proxies logger = logging.getLogger(__name__) @@ -17,7 +22,12 @@ UPDATE_CHANNELS = ("stable", "nightly") DEFAULT_UPDATE_CHANNEL = "stable" DEFAULT_CHECK_AUTOMATICALLY = True -_GITHUB_REPO_RE = re.compile(r"https?://github\.com/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$") +#: Release source for source installs: this fork's repository on Gitea. +UPDATE_REPO_URL = "https://git.bongbetic.com/xavierk/voculinux" + +_REPO_URL_RE = re.compile( + r"^https://(?P[^/]+)/(?P[^/]+)/(?P[^/]+?)(?:\.git)?/?$" +) _PRE_RELEASE_SUFFIX_RE = re.compile( r"^(?P
\d+(?:\.\d+)*)(?:[-.](?P
(?:alpha|beta|rc|pre|dev)\.?\d*))?$",
     re.IGNORECASE,
@@ -29,7 +39,7 @@ _DEV_DATE_RE = re.compile(r"(?:^|[.+-])dev(\d{8})(?:[.+-]|$)", re.IGNORECASE)
 
 @dataclass(frozen=True)
 class ReleaseInfo:
-    """Metadata for a GitHub release."""
+    """Metadata for a release published on the release server."""
 
     tag_name: str
     version: str
@@ -47,16 +57,17 @@ def normalize_channel(channel: Optional[str]) -> str:
     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())
+def _repo_parts(repo_url: str = UPDATE_REPO_URL) -> tuple[str, str, str]:
+    """Return ``(host, owner, repo)`` for an https Gitea repository URL."""
+    match = _REPO_URL_RE.match(repo_url.strip())
     if not match:
         raise ValueError(f"Unsupported repository URL: {repo_url}")
-    return match.group("owner"), match.group("repo")
+    return match.group("host"), 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 _repo_api_base(repo_url: str = UPDATE_REPO_URL) -> str:
+    host, owner, repo = _repo_parts(repo_url)
+    return f"https://{host}/api/v1/repos/{owner}/{repo}"
 
 
 def normalize_version(version: str) -> str:
@@ -139,44 +150,24 @@ def is_update_available(
     return is_newer_version(current, latest.version)
 
 
-# github.com/jatinkrmalik/vocalinux redirects here after the org transfer.
-_VOCULINUX_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 _VOCULINUX_GITHUB_OWNERS}
-    if repo.casefold() == "vocalinux" and owner.casefold() in known_owners:
-        prefixes.extend(f"/{name}/vocalinux" for name in sorted(_VOCULINUX_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."""
+def is_trusted_release_url(url: str, repo_url: str = UPDATE_REPO_URL) -> bool:
+    """Return True when ``url`` points at this project's release pages on Gitea."""
     if not url:
         return False
     try:
+        host, owner, repo = _repo_parts(repo_url)
         parsed = urlparse(url)
-        prefixes = _trusted_path_prefixes(repo_url)
     except ValueError:
         return False
-    if parsed.scheme != "https" or parsed.netloc.lower() != "github.com":
+    if parsed.scheme != "https" or parsed.netloc.lower() != host.lower():
         return False
-    path = parsed.path.rstrip("/")
-    return any(path == prefix or path.startswith(prefix + "/") for prefix in prefixes)
+    path = parsed.path.rstrip("/").casefold()
+    prefix = f"/{owner}/{repo}".casefold()
+    return path == prefix or path.startswith(prefix + "/")
 
 
 def _release_from_payload(
-    data: dict, channel: str, repo_url: str = __url__
+    data: dict, channel: str, repo_url: str = UPDATE_REPO_URL
 ) -> Optional[ReleaseInfo]:
     tag_name = data.get("tag_name") or ""
     if not tag_name:
@@ -199,71 +190,26 @@ def _release_from_payload(
     )
 
 
-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": UPDATE_CHECKER_USER_AGENT,
-    }
-    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__,
+    repo_url: str = UPDATE_REPO_URL,
     timeout: float = 10.0,
     channel: str = DEFAULT_UPDATE_CHANNEL,
 ) -> Optional[ReleaseInfo]:
-    """Fetch the newest GitHub release for ``channel`` (``stable`` or ``nightly``)."""
+    """Fetch the newest Gitea release for ``channel`` (``stable`` or ``nightly``).
+
+    A packaged install returns None without importing ``requests`` or opening a
+    connection: its updates arrive through the system package manager.
+    """
+    if is_packaged_install():
+        logger.debug("Packaged install: release check skipped, updates come from xbps")
+        return None
+
     import requests
 
     channel = normalize_channel(channel)
     api_base = _repo_api_base(repo_url)
     headers = {
-        "Accept": "application/vnd.github+json",
+        "Accept": "application/json",
         "User-Agent": UPDATE_CHECKER_USER_AGENT,
     }
 
@@ -279,7 +225,8 @@ def fetch_latest_release(
                 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)
+                logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
+                return None
             response.raise_for_status()
             data = response.json()
             if not isinstance(data, dict):
@@ -293,12 +240,12 @@ def fetch_latest_release(
             response = requests.get(
                 f"{api_base}/releases",
                 headers=headers,
-                params={"per_page": 30, "page": page},
+                params={"limit": 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)
+                logger.debug("Release lookup rate-limited or forbidden for %s", api_base)
                 return None
             response.raise_for_status()
             releases = response.json()
diff --git a/src/vocalinux/utils/update_monitor.py b/src/vocalinux/utils/update_monitor.py
index 6663be3..d9d7438 100644
--- a/src/vocalinux/utils/update_monitor.py
+++ b/src/vocalinux/utils/update_monitor.py
@@ -1,4 +1,4 @@
-"""Background GitHub release checks for Voculinux.
+"""Background release checks for Voculinux.
 
 Schedules a delayed first check, then repeats on an interval. Networking runs on
 a worker thread; the result callback is delivered on the GLib main loop.
diff --git a/tests/test_packaged_install.py b/tests/test_packaged_install.py
index 80eb6fb..c645183 100644
--- a/tests/test_packaged_install.py
+++ b/tests/test_packaged_install.py
@@ -38,8 +38,8 @@ def test_packaged_install_when_the_marker_exists(tmp_path: Path) -> None:
 
 def test_packaged_hint_names_the_package_manager_path() -> None:
     """The presented path is xbps, not a release download."""
-    assert "package manager" in PACKAGED_UPDATE_HINT
-    assert "xbps-install -Syu voculinux" in PACKAGED_UPDATE_HINT
+    assert "xbps-install -Su" in PACKAGED_UPDATE_HINT
+    assert "updates arrive via sudo xbps-install -Su" in PACKAGED_UPDATE_HINT
 
 
 def test_unpackaged_menu_label_is_unchanged() -> None:
@@ -90,4 +90,4 @@ def test_packaged_updates_description_presents_the_package_manager() -> None:
     """The About page tells packaged users to update through xbps."""
     description = updates_description("stable", packaged=True)
     assert "stable" in description
-    assert "package manager" in description
+    assert "xbps-install -Su" in description
diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py
index 36f9706..f0739b4 100644
--- a/tests/test_settings_dialog.py
+++ b/tests/test_settings_dialog.py
@@ -1476,9 +1476,10 @@ class TestAboutPage(unittest.TestCase):
             VOCAHQ_DISCORD_URL,
             VOCAHQ_X_URL,
             VOCAHQ_MAILTO_URL,
-            "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
+            "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
         ):
             self.assertTrue(_can_open_url(url), url)
+        self.assertFalse(_can_open_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0"))
         self.assertFalse(_can_open_url("https://evil.example/payload"))
         self.assertFalse(_can_open_url("mailto:evil@example.com"))
         self.assertFalse(_can_open_url(""))
@@ -1644,6 +1645,15 @@ class TestUpdatePreferences(unittest.TestCase):
         self.assertIn("self._start_update_check()", method)
         self.assertIn("Automatic checks are off. Press Check to look now.", method)
 
+    def test_packaged_check_now_explains_xbps_before_any_lookup(self) -> None:
+        """A packaged install's About page shows the xbps path and starts no thread."""
+        method = self._method_source(self.settings_source, "_start_update_check")
+        guard = method.index("if is_packaged_install():")
+        packaged_branch = method[guard : method.index("return", guard)]
+
+        self.assertIn("self.update_status_row.set_subtitle(PACKAGED_UPDATE_HINT)", packaged_branch)
+        self.assertLess(guard, method.index("threading.Thread"))
+
     def test_update_switch_loads_state_before_connecting(self) -> None:
         switch_start = self.about_source.index("self.update_checks_switch = Gtk.Switch()")
         active_position = self.about_source.index(
diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py
index 90607c5..fd7767d 100644
--- a/tests/test_update_checker.py
+++ b/tests/test_update_checker.py
@@ -1,8 +1,10 @@
-"""Tests for the GitHub Releases update checker."""
+"""Tests for the Voculinux release checker (Gitea, never upstream VocaHQ)."""
 
 import logging
 from unittest.mock import MagicMock, patch
 
+import pytest
+
 from vocalinux.utils.update_checker import (
     ReleaseInfo,
     build_channel,
@@ -72,7 +74,7 @@ class TestChannels:
             tag_name="v0.15.0",
             version="0.15.0",
             name="v0.15.0",
-            html_url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
+            html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
             body="",
             published_at="2026-08-02T00:00:00Z",
             prerelease=False,
@@ -86,7 +88,7 @@ class TestChannels:
             tag_name="nightly-2026-08-02",
             version="nightly-2026-08-02",
             name="Nightly Build - 0.14.2.dev20260802+27ecf88",
-            html_url="https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
+            html_url="https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
             body="",
             published_at="2026-08-02T00:00:00Z",
             prerelease=True,
@@ -103,20 +105,22 @@ class TestChannels:
 
 class TestTrustedUrls:
     def test_accepts_project_urls(self):
-        assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
-        assert is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0")
-        # Pre-transfer URLs still redirect and must stay trusted.
-        assert is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
+        assert is_trusted_release_url("https://git.bongbetic.com/xavierk/voculinux")
         assert is_trusted_release_url(
-            "https://github.com/jatinkrmalik/vocalinux/releases/tag/v0.15.0"
+            "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0"
         )
 
-    def test_rejects_untrusted_urls(self):
-        assert not is_trusted_release_url("https://evil.example/payload")
-        assert not is_trusted_release_url("http://github.com/VocaHQ/vocalinux")
-        assert not is_trusted_release_url("https://github.com/evil/voculinux")
+    def test_rejects_upstream_and_other_urls(self):
+        assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux")
+        assert not is_trusted_release_url("https://github.com/VocaHQ/vocalinux/releases/tag/v1")
+        assert not is_trusted_release_url("https://github.com/jatinkrmalik/vocalinux")
+        assert not is_trusted_release_url("https://git.bongbetic.com/xavierk/other")
+        assert not is_trusted_release_url("https://evil.example/xavierk/voculinux")
         assert not is_trusted_release_url("")
 
+    def test_rejects_non_https_urls(self):
+        assert not is_trusted_release_url("http://git.bongbetic.com/xavierk/voculinux")
+
 
 class TestFormatReleaseNotes:
     def test_empty_body(self):
@@ -150,7 +154,7 @@ class TestFetchLatestRelease:
         mock_response.json.return_value = {
             "tag_name": "v0.15.0",
             "name": "v0.15.0",
-            "html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
+            "html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
             "body": "Notes",
             "published_at": "2026-08-02T22:29:58Z",
             "prerelease": False,
@@ -167,7 +171,7 @@ class TestFetchLatestRelease:
         assert release.html_url.endswith("/v0.15.0")
         mock_requests.get.assert_called_once()
         api_url = mock_requests.get.call_args[0][0]
-        assert api_url == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
+        assert api_url == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
 
     def test_drops_untrusted_html_url(self):
         mock_response = MagicMock()
@@ -197,7 +201,7 @@ class TestFetchLatestRelease:
             {
                 "tag_name": "v0.15.0",
                 "name": "v0.15.0",
-                "html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.0",
+                "html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.0",
                 "body": "Stable",
                 "published_at": "2026-08-02T22:29:58Z",
                 "prerelease": False,
@@ -206,7 +210,7 @@ class TestFetchLatestRelease:
             {
                 "tag_name": "nightly-2026-08-02",
                 "name": "nightly-2026-08-02",
-                "html_url": "https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-02",
+                "html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-02",
                 "body": "Nightly",
                 "published_at": "2026-08-02T05:00:00Z",
                 "prerelease": True,
@@ -232,7 +236,7 @@ class TestFetchLatestRelease:
             {
                 "tag_name": f"v0.15.{i}",
                 "name": f"v0.15.{i}",
-                "html_url": f"https://github.com/VocaHQ/vocalinux/releases/tag/v0.15.{i}",
+                "html_url": f"https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.15.{i}",
                 "body": "",
                 "published_at": "2026-08-03T00:00:00Z",
                 "prerelease": False,
@@ -244,7 +248,9 @@ class TestFetchLatestRelease:
             {
                 "tag_name": "nightly-2026-08-01",
                 "name": "nightly-2026-08-01",
-                "html_url": ("https://github.com/VocaHQ/vocalinux/releases/tag/nightly-2026-08-01"),
+                "html_url": (
+                    "https://git.bongbetic.com/xavierk/voculinux/releases/tag/nightly-2026-08-01"
+                ),
                 "body": "Nightly",
                 "published_at": "2026-08-01T05:00:00Z",
                 "prerelease": True,
@@ -271,92 +277,45 @@ class TestFetchLatestRelease:
         assert mock_requests.get.call_args_list[0].kwargs["params"]["page"] == 1
         assert mock_requests.get.call_args_list[1].kwargs["params"]["page"] == 2
 
-    def test_stable_api_403_uses_html_fallback(self, caplog):
-        release = self._fetch_stable_with_rate_limit(403, caplog)
-        assert isinstance(release, ReleaseInfo)
-        assert release.version == "0.17.0"
-        assert release.channel == "stable"
-        assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
-
-    def test_stable_api_429_uses_html_fallback(self, caplog):
-        release = self._fetch_stable_with_rate_limit(429, caplog)
-        assert isinstance(release, ReleaseInfo)
-        assert release.version == "0.17.0"
-        assert release.channel == "stable"
-        assert release.html_url == "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
-
-    def test_stable_api_403_fallback_failures_return_none_quietly(self, caplog):
-        api_response = _mock_response(403)
-        failures = [
-            _mock_response(
-                500,
-                url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
-            ),
-            Exception("offline"),
-            _mock_response(
-                200,
-                json_value=["not", "a", "dict"],
-                url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
-            ),
-            _mock_response(
-                200,
-                json_value={"update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0"},
-                url="https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
-            ),
-        ]
+    def test_stable_404_means_no_release_and_warns(self, caplog):
+        """A missing or private repo answers 404 anonymously: no release, one warning."""
+        mock_requests = _requests_mock([_mock_response(404)])
         caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
-        for failure in failures:
-            mock_requests = _requests_mock([api_response, failure])
-            with patch.dict("sys.modules", {"requests": mock_requests}):
-                assert fetch_latest_release() is None
-            assert _checker_warnings(caplog) == []
-            caplog.clear()
+        with patch.dict("sys.modules", {"requests": mock_requests}):
+            assert fetch_latest_release() is None
+        mock_requests.get.assert_called_once()
+        assert len(_checker_warnings(caplog)) == 1
 
-    def test_evil_update_url_does_not_become_html_url(self, caplog):
-        tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
-        evil_paths = [
-            "/evil/voculinux/releases/tag/v9",
-            "https://evil.example/x",
-            "/VocaHQ/vocalinux/releases/../evil",
-        ]
+    @pytest.mark.parametrize("status_code", [403, 429])
+    def test_stable_403_and_429_return_none_without_a_second_request(self, caplog, status_code):
+        mock_requests = _requests_mock([_mock_response(status_code)])
         caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
-        for update_url in evil_paths:
-            api_response = _mock_response(403)
-            fallback = _mock_response(
-                200,
-                json_value={"tag_name": "v0.17.0", "update_url": update_url},
-                url=tag_url,
-            )
-            mock_requests = _requests_mock([api_response, fallback])
-            with patch.dict("sys.modules", {"requests": mock_requests}):
-                release = fetch_latest_release()
-            assert release is not None
-            assert release.version == "0.17.0"
-            assert release.html_url == ""
-            assert _checker_warnings(caplog) == []
-            caplog.clear()
+        with patch.dict("sys.modules", {"requests": mock_requests}):
+            assert fetch_latest_release() is None
+        mock_requests.get.assert_called_once()
+        assert _checker_warnings(caplog) == []
 
-    def test_untrusted_fallback_final_url_is_rejected(self, caplog):
+    def test_prerelease_tag_is_compared_not_filtered(self):
+        """Existing comparison rules apply to a pre-release tag: no new filter."""
         payload = {
-            "tag_name": "v0.17.0",
-            "update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
+            "tag_name": "v0.18.0-beta.1",
+            "name": "v0.18.0-beta.1",
+            "html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.0-beta.1",
+            "body": "Beta",
+            "published_at": "2026-08-02T22:29:58Z",
+            "prerelease": True,
         }
-        untrusted_urls = (
-            "https://evil.example/VocaHQ/vocalinux/releases/tag/v0.17.0",
-            "https://github.com/evil/voculinux/releases/tag/v0.17.0",
-            "http://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0",
-        )
-        caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
-        for final_url in untrusted_urls:
-            api_response = _mock_response(403)
-            fallback = _mock_response(200, json_value=payload, url=final_url)
-            mock_requests = _requests_mock([api_response, fallback])
-            with patch.dict("sys.modules", {"requests": mock_requests}):
-                assert fetch_latest_release() is None
-            assert _checker_warnings(caplog) == []
-            caplog.clear()
+        mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
+        with patch.dict("sys.modules", {"requests": mock_requests}):
+            release = fetch_latest_release()
 
-    def test_nightly_api_403_returns_none_without_html_fallback(self, caplog):
+        assert release is not None
+        assert release.prerelease is True
+        assert release.version == "0.18.0-beta.1"
+        assert is_update_available("0.17.0", release) is True
+        assert is_update_available("0.18.0", release) is False
+
+    def test_nightly_api_403_returns_none(self, caplog):
         api_response = _mock_response(403)
         mock_requests = _requests_mock([api_response])
         caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
@@ -364,46 +323,58 @@ class TestFetchLatestRelease:
             assert fetch_latest_release(channel="nightly") is None
         mock_requests.get.assert_called_once()
         requested = mock_requests.get.call_args[0][0]
-        assert requested == "https://api.github.com/repos/VocaHQ/vocalinux/releases"
+        assert requested == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases"
         assert _checker_warnings(caplog) == []
 
-    def _fetch_stable_with_rate_limit(self, status_code: int, caplog):
-        tag_url = "https://github.com/VocaHQ/vocalinux/releases/tag/v0.17.0"
-        api_response = _mock_response(status_code)
-        fallback = _mock_response(
-            200,
-            json_value={
-                "tag_name": "v0.17.0",
-                "update_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
-                "id": 1,
-                "edit_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0/edit",
-                "delete_url": "/VocaHQ/vocalinux/releases/tag/v0.17.0",
-                "update_authenticity_token": "csrf-update",
-                "delete_authenticity_token": "csrf-delete",
-            },
-            url=tag_url,
-        )
-        mock_requests = _requests_mock([api_response, fallback])
-        caplog.set_level(logging.WARNING, logger="vocalinux.utils.update_checker")
+    def test_stable_lookup_parses_gitea_payload_from_gitea_only(self, caplog):
+        """The stable lookup reads Gitea's JSON and never contacts GitHub."""
+        payload = {
+            "tag_name": "v0.18.6",
+            "name": "v0.18.6",
+            "html_url": "https://git.bongbetic.com/xavierk/voculinux/releases/tag/v0.18.6",
+            "body": "Notes",
+            "published_at": "2026-11-06T10:00:00Z",
+            "prerelease": False,
+        }
+        mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
         with patch.dict("sys.modules", {"requests": mock_requests}):
             release = fetch_latest_release()
 
-        assert mock_requests.get.call_count == 2
-        first, second = mock_requests.get.call_args_list
-        assert first[0][0] == "https://api.github.com/repos/VocaHQ/vocalinux/releases/latest"
-        assert first.kwargs["headers"]["Accept"] == "application/vnd.github+json"
+        assert release is not None
+        assert release.tag_name == "v0.18.6"
+        assert release.html_url == payload["html_url"]
+        assert release.body == "Notes"
+        mock_requests.get.assert_called_once()
+        first = mock_requests.get.call_args
+        assert (
+            first[0][0]
+            == "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/releases/latest"
+        )
+        assert first.kwargs["headers"]["Accept"] == "application/json"
         assert first.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
         assert "Authorization" not in first.kwargs["headers"]
-        assert second[0][0] == "https://github.com/VocaHQ/vocalinux/releases/latest"
-        assert second.kwargs["headers"]["Accept"] == "application/json"
-        assert second.kwargs["headers"]["User-Agent"] == "Voculinux-UpdateChecker"
-        assert "Authorization" not in second.kwargs["headers"]
-        assert fallback.url == tag_url
-        assert _checker_warnings(caplog) == []
-        assert release is not None
-        assert "csrf-update" not in repr(release)
-        assert "csrf-delete" not in repr(release)
-        return release
+
+    @pytest.mark.parametrize("channel", ["stable", "nightly"])
+    def test_source_install_never_queries_upstream(self, channel):
+        payload = [] if channel == "nightly" else {"tag_name": "v0.18.6"}
+        mock_requests = _requests_mock([_mock_response(200, json_value=payload)])
+        with patch.dict("sys.modules", {"requests": mock_requests}):
+            fetch_latest_release(channel=channel)
+        for call in mock_requests.get.call_args_list:
+            assert call[0][0].startswith(
+                "https://git.bongbetic.com/api/v1/repos/xavierk/voculinux/"
+            )
+
+    def test_packaged_install_makes_no_network_request(self):
+        """Packaged installs get updates from xbps, so the checker stays off the network."""
+        mock_requests = _requests_mock([_mock_response(200, json_value={"tag_name": "v9.9.9"})])
+        with (
+            patch("vocalinux.utils.update_checker.is_packaged_install", return_value=True),
+            patch.dict("sys.modules", {"requests": mock_requests}),
+        ):
+            assert fetch_latest_release() is None
+            assert fetch_latest_release(channel="nightly") is None
+        mock_requests.get.assert_not_called()
 
 
 def _mock_response(status_code, json_value=None, url=""):