aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-20 11:16:01 +0200
committerDanilo M. <danix@danix.xyz>2026-10-20 11:16:01 +0200
commit2989606c3c0bcf08e99468067b0ab73c6810aa1c (patch)
treee75d2068afaf1149b1e85db49f2b8abc27df3fe1
parent138370e7e8dee7bbc35b96264c1b11be57d6d45a (diff)
downloadsb-builder-2989606c3c0bcf08e99468067b0ab73c6810aa1c.tar.gz
sb-builder-2989606c3c0bcf08e99468067b0ab73c6810aa1c.zip
Upstream versions from nvchecker on the desktop
nvchecker.toml moves from the VM into the repo. The GUI runs nvchecker with its own [__config__] (the keyfile from [hints]) and reads versions and per-package errors from --logger json; outdated is decided against the hints, so no oldver, newver or nvtake. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--config.example.toml3
-rw-r--r--nvchecker.toml400
-rw-r--r--sb_builder/config.py1
-rw-r--r--sb_builder/upstream.py109
-rw-r--r--tests/test_upstream.py112
5 files changed, 625 insertions, 0 deletions
diff --git a/config.example.toml b/config.example.toml
index d5fd2c3..e192a28 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -20,3 +20,6 @@ autostart = false # true: start the VM without asking when it is off
[builds]
publish = true # initial state of the "publish" box next to Run it
+
+[hints]
+nvchecker_keyfile = "~/.config/nvchecker/keys.toml" # GitHub token for nvchecker; optional
diff --git a/nvchecker.toml b/nvchecker.toml
new file mode 100644
index 0000000..4318516
--- /dev/null
+++ b/nvchecker.toml
@@ -0,0 +1,400 @@
+# sb-builder upstream versions: one nvchecker stanza per package, named after it.
+# Edited by hand. The GUI prepends its own [__config__] (keyfile) when it runs
+# nvchecker; do not add one here. Sources: https://nvchecker.readthedocs.io
+
+[borgbackup]
+source = "github"
+github = "borgbackup/borg"
+use_latest_release = true
+prefix = "v"
+
+[chromaprint]
+source = "github"
+github = "acoustid/chromaprint"
+use_max_tag = true
+prefix = "v"
+
+["claude-code-bin"]
+source = "github"
+github = "anthropics/claude-code"
+use_latest_release = true
+prefix = "v"
+
+[composer]
+# TODO: configure nvchecker source for "composer"
+# source = "regex"
+# url = "..."
+# regex = "..."
+# see https://nvchecker.readthedocs.io/en/latest/usage.html
+
+[conky]
+source = "github"
+github = "brndnmtthws/conky"
+use_latest_release = true
+prefix = "v"
+
+[containerd]
+# TODO: configure nvchecker source for "containerd"
+# source = "regex"
+# url = "..."
+# regex = "..."
+# see https://nvchecker.readthedocs.io/en/latest/usage.html
+
+[ddgr]
+source = "github"
+github = "jarun/ddgr"
+use_max_tag = true
+prefix = "v"
+
+[discord]
+source = "httpheader"
+url = "https://discord.com/api/download?platform=linux&format=tar.gz"
+regex = "discord-(.*)\\.tar.*"
+
+["docker-cli"]
+# TODO: configure nvchecker source for "docker-cli"
+# source = "regex"
+# url = "..."
+# regex = "..."
+# see https://nvchecker.readthedocs.io/en/latest/usage.html
+
+[exploitdb]
+source = "gitlab"
+gitlab = "exploit-database/exploitdb"
+use_max_tag = true
+prefix = "v"
+
+[ffuf]
+source = "github"
+github = "ffuf/ffuf"
+use_max_tag = true
+prefix = "v"
+
+[gamescope]
+source = "github"
+github = "ValveSoftware/gamescope"
+use_max_tag = true
+prefix = "v"
+
+["github-cli"]
+source = "github"
+github = "cli/cli"
+use_max_tag = true
+prefix = "v"
+
+[hugo]
+source = "github"
+github = "gohugoio/hugo"
+use_latest_release = true
+prefix = "v"
+
+[hydra]
+source = "github"
+github = "vanhauser-thc/thc-hydra"
+use_max_tag = true
+prefix = "v"
+
+["jellyfin-bin"]
+source = "github"
+github = "jellyfin/jellyfin"
+use_latest_release = true
+prefix = "v"
+
+[kitty]
+source = "github"
+github = "kovidgoyal/kitty"
+use_latest_release = true
+prefix = "v"
+
+["kvantum-qt6"]
+source = "github"
+github = "tsujan/Kvantum"
+use_max_tag = true
+prefix = "V"
+
+["layer-shell-qt6"]
+# TODO: configure nvchecker source for "layer-shell-qt6"
+# source = "regex"
+# url = "..."
+# regex = "..."
+# see https://nvchecker.readthedocs.io/en/latest/usage.html
+
+["llama.cpp-vulkan"]
+source = "github"
+github = "ggml-org/llama.cpp"
+use_latest_release = true
+
+["llvm-opt"]
+source = "github"
+github = "llvm/llvm-project"
+use_latest_release = true
+prefix = "llvmorg-"
+
+["metasploit-framework"]
+source = "regex"
+url = "https://apt.metasploit.com/"
+regex = "metasploit-framework_([A-Za-z0-9._-]+)~.*_amd64.deb"
+
+[neovim]
+source = "github"
+github = "neovim/neovim"
+use_max_tag = true
+prefix = "v"
+
+[nessus]
+source = "jq"
+url = "https://www.tenable.com/downloads/api/v2/pages/nessus"
+filter = ".releases.latest | keys[0] | split(\" - \")[1]"
+
+[nuclei]
+source = "github"
+github = "projectdiscovery/nuclei"
+use_max_tag = true
+prefix = "v"
+
+["opencode-bin"]
+source = "github"
+github = "anomalyco/opencode"
+use_latest_release = true
+prefix = "v"
+
+[powershell]
+source = "github"
+github = "Powershell/Powershell"
+use_latest_release = true
+prefix = "v"
+
+["python3-filelock"]
+source = "github"
+github = "tox-dev/filelock"
+use_max_tag = true
+prefix = "v"
+
+[qtpass]
+source = "github"
+github = "IJHack/QtPass"
+use_max_tag = true
+prefix = "v"
+
+[runc]
+source = "github"
+github = "opencontainers/runc"
+use_latest_release = true
+prefix = "v"
+
+["signal-desktop"]
+source = "github"
+github = "signalapp/Signal-Desktop"
+use_latest_release = true
+prefix = "v"
+
+[solvespace]
+source = "github"
+github = "solvespace/solvespace"
+use_max_tag = true
+prefix = "v"
+
+[spotify]
+# TODO: configure nvchecker source for "spotify"
+# source = "regex"
+# url = "..."
+# regex = "..."
+# see https://nvchecker.readthedocs.io/en/latest/usage.html
+
+[steam]
+source = "regex"
+url = "https://repo.steampowered.com/steam/archive/stable/"
+regex = "steam-launcher_([\\d.]+)_amd64\\.deb"
+
+[sublime_text]
+source = "jq"
+url = "https://www.sublimetext.com/updates/4/stable_update_check"
+filter = ".latest_version"
+# upstream reports build 4215, SBo VERSION is 4.2.15
+from_pattern = '^(\d)(\d)(\d\d)$'
+to_pattern = '\1.\2.\3'
+
+[syncthing]
+source = "github"
+github = "syncthing/syncthing"
+use_latest_release = true
+prefix = "v"
+
+[tini]
+source = "github"
+github = "krallin/tini"
+use_max_tag = true
+prefix = "v"
+
+["uv-bin"]
+source = "github"
+github = "astral-sh/uv"
+use_max_tag = true
+prefix = "v"
+
+["yt-dlp"]
+source = "github"
+github = "yt-dlp/yt-dlp"
+use_max_tag = true
+prefix = "v"
+
+
+[telegram]
+source = "github"
+github = "telegramdesktop/tdesktop"
+use_latest_release = true
+prefix = "v"
+
+["go-md2man"]
+source = "github"
+github = "cpuguy83/go-md2man"
+use_max_tag = true
+prefix = "v"
+
+[slackrepo]
+source = "github"
+github = "aclemons/slackrepo"
+use_max_tag = true
+prefix = "v"
+
+["slackrepo-hints"]
+source = "github"
+github = "aclemons/slackrepo-hints"
+use_max_tag = true
+prefix = "v"
+
+[feroxbuster]
+source = "github"
+github = "epi052/feroxbuster"
+use_latest_release = "true"
+prefix = "v"
+
+[flatpak]
+source = "github"
+github = "flatpak/flatpak"
+use_max_tag = true
+prefix = "v"
+
+[ostree]
+source = "github"
+github = "ostreedev/ostree"
+use_max_tag = true
+prefix = "v"
+
+[twine]
+source = "github"
+github = "pypa/twine"
+use_max_tag = true
+prefix = "v"
+
+["firefly-cli"]
+source = "github"
+github = "danixland/firefly-cli"
+use_max_tag = true
+prefix = "v"
+
+[UrbanTerror]
+source = "regex"
+url = "https://www.urbanterror.info/downloads/"
+regex = "UrbanTerror(\\d+)_full\\.zip"
+from_pattern = "(\\d)(\\d)(\\d)"
+to_pattern = "\\1.\\2.\\3"
+
+
+["gitea-cli"]
+source = "gitea"
+gitea = "gitea/tea"
+use_max_tag = true
+prefix = "v"
+
+
+[tbb]
+source = "github"
+github = "oneapi-src/oneTBB"
+use_latest_release = true
+prefix = "v"
+
+[openvino]
+source = "github"
+github = "openvinotoolkit/openvino"
+use_latest_release = true
+# use_max_tag = true # if the repo publishes no Releases
+# prefix = "v" # uncomment if tags are v-prefixed
+
+["kvantum-qt5"]
+source = "github"
+github = "tsujan/Kvantum"
+use_latest_release = true
+prefix = "V" # uncomment if tags are v-prefixed
+
+[hwloc]
+source = "github"
+github = "open-mpi/hwloc"
+prefix = "hwloc-"
+use_latest_release = true
+
+[xbyak]
+source = "github"
+github = "herumi/xbyak"
+use_max_tag = true
+prefix = "v"
+
+["OpenCL-CLHPP"]
+source = "github"
+github = "KhronosGroup/OpenCL-CLHPP"
+use_latest_release = true
+prefix = "v" # uncomment if tags are v-prefixed
+# use_max_tag = true # if the repo publishes no Releases
+
+["docker-buildx"]
+source = "github"
+github = "docker/buildx"
+use_latest_release = true
+prefix = "v" # uncomment if tags are v-prefixed
+
+["claude-code"]
+source = "github"
+github = "anthropics/claude-code"
+use_latest_release = true
+prefix = "v" # uncomment if tags are v-prefixed
+
+["teams-for-linux"]
+source = "github"
+github = "IsmaelMartinez/teams-for-linux"
+use_latest_release = true
+prefix = "v" # uncomment if tags are v-prefixed
+
+[vscodium]
+source = "github"
+github = "VSCodium/vscodium"
+use_latest_release = true
+prefix = "v"
+
+[neomutt]
+source = "github"
+github = "neomutt/neomutt"
+use_latest_release = true
+# use_max_tag = true # if the repo publishes no Releases
+# prefix = "v" # uncomment if tags are v-prefixed
+
+["python3-telethon"]
+source = "gitea"
+gitea = "Lonami/Telethon"
+host = "codeberg.org"
+use_max_tag = true
+prefix = "v" # uncomment if tags are v-prefixed
+
+[obsidian]
+source = "github"
+github = "obsidianmd/obsidian-releases"
+use_latest_release = true
+prefix = "v"
+
+
+["pcmanfm-qt"]
+source = "github"
+github = "lxqt/pcmanfm-qt"
+use_latest_release = true
+# use_max_tag = true # if the repo publishes no Releases
+prefix = "v" # uncomment if tags are v-prefixed
+
diff --git a/sb_builder/config.py b/sb_builder/config.py
index da1d388..8164f75 100644
--- a/sb_builder/config.py
+++ b/sb_builder/config.py
@@ -26,6 +26,7 @@ DEFAULTS = {
"notify": {"enabled": True, "events": ["finished", "failed", "killed", "lost", "vm-up"]},
"vm": {"libvirt_uri": "qemu:///system", "domain": "buildsystem", "autostart": False},
"builds": {"publish": True},
+ "hints": {"nvchecker_keyfile": "~/.config/nvchecker/keys.toml"},
}
diff --git a/sb_builder/upstream.py b/sb_builder/upstream.py
new file mode 100644
index 0000000..d936706
--- /dev/null
+++ b/sb_builder/upstream.py
@@ -0,0 +1,109 @@
+# sb-builder - upstream versions through nvchecker
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Upstream versions: nvchecker run on the desktop over this repo's nvchecker.toml.
+
+No oldver/newver files: nvchecker's JSON log on stdout carries each version,
+and "outdated" is decided against the hints, not against a previous run.
+"""
+
+import json
+import os
+import tempfile
+import tomllib
+
+from PyQt6.QtCore import QObject, QProcess, pyqtSignal
+
+STANZAS = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nvchecker.toml")
+
+
+def stanzas(path=STANZAS):
+ """Package names with a stanza; an unreadable file has none."""
+ try:
+ with open(path, "rb") as f:
+ return set(tomllib.load(f)) - {"__config__"}
+ except (OSError, tomllib.TOMLDecodeError):
+ return set()
+
+
+def write_config(tmpdir, keyfile, path=STANZAS):
+ """This run's config: a [__config__] with the keyfile (when present), then the stanzas."""
+ head = "[__config__]\n"
+ if os.path.isfile(keyfile):
+ head += f"keyfile = {json.dumps(keyfile)}\n" # a JSON string is a TOML basic string
+ with open(path) as f:
+ body = f.read()
+ out = os.path.join(tmpdir, "nvchecker.toml")
+ with open(out, "w") as f:
+ f.write(head + "\n" + body)
+ return out
+
+
+def parse_log(text):
+ """({name: version}, {name: error}) from nvchecker --logger json output."""
+ versions, errors = {}, {}
+ for line in text.splitlines():
+ try:
+ e = json.loads(line)
+ except ValueError:
+ continue
+ if not isinstance(e, dict) or "name" not in e:
+ continue
+ if e.get("version"):
+ versions[e["name"]] = e["version"]
+ elif e.get("level") == "error":
+ errors.setdefault(e["name"], str(e.get("error") or e.get("event")))
+ return versions, errors
+
+
+def state(row, names, versions, errors):
+ """Hints screen state of an inventory row; versions None = not checked yet."""
+ name = row["name"]
+ if name not in names:
+ return "no stanza"
+ if versions is None:
+ return "not checked"
+ if name not in versions:
+ return "nvchecker error"
+ return "current" if versions[name] == (row["hint_version"] or row["info_version"]) else "outdated"
+
+
+class Checker(QObject):
+ """One nvchecker run at a time; done(versions, errors, failure text or "")."""
+
+ done = pyqtSignal(dict, dict, str)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.proc = None
+
+ def run(self, keyfile, path=STANZAS):
+ tmp = tempfile.TemporaryDirectory()
+ p = self.proc = QProcess(self)
+
+ def finished(rc=-1, _status=None):
+ out = bytes(p.readAllStandardOutput()).decode(errors="replace")
+ err = bytes(p.readAllStandardError()).decode(errors="replace").strip()
+ tmp.cleanup()
+ self.proc = None
+ p.deleteLater()
+ versions, errors = parse_log(out)
+ self.done.emit(versions, errors, "" if rc == 0 else err or f"nvchecker: exit {rc}")
+
+ p.finished.connect(finished)
+ p.errorOccurred.connect(
+ lambda e: e == QProcess.ProcessError.FailedToStart and finished(-1))
+ p.start("nvchecker", ["-c", write_config(tmp.name, keyfile, path), "--logger", "json"])
diff --git a/tests/test_upstream.py b/tests/test_upstream.py
new file mode 100644
index 0000000..38cba54
--- /dev/null
+++ b/tests/test_upstream.py
@@ -0,0 +1,112 @@
+# sb-builder - self-checks for the nvchecker glue
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+"""Run: python3 -m unittest discover -s tests (no network: nvchecker is faked)"""
+
+import os
+os.environ["QT_QPA_PLATFORM"] = "offscreen"
+
+import shutil # noqa: E402
+import sys # noqa: E402
+import tempfile # noqa: E402
+import time # noqa: E402
+import tomllib # noqa: E402
+import unittest # noqa: E402
+from unittest import mock # noqa: E402
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
+sys.path.insert(0, ROOT)
+
+from PyQt6.QtWidgets import QApplication # noqa: E402
+
+from sb_builder import upstream # noqa: E402
+
+app = QApplication.instance() or QApplication([])
+LOG = "\n".join([
+ '{"name": "borgbackup", "version": "1.4.5", "event": "updated", "level": "info"}',
+ '{"name": "bogus", "event": "unexpected error happened", "error": "HTTPError(404)", "level": "error"}',
+ '{"name": "bogus", "event": "no-result", "error": "later", "level": "error"}',
+ 'not json',
+])
+
+
+def row(name, hint=None, info="1.0"):
+ return {"name": name, "hint_version": hint, "info_version": info}
+
+
+class UpstreamTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.tmp)
+ self.stanzas = os.path.join(self.tmp, "stanzas.toml")
+ with open(self.stanzas, "w") as f:
+ f.write('[x]\nsource = "manual"\nmanual = "2.0"\n')
+
+ def test_repo_file(self):
+ with open(upstream.STANZAS, "rb") as f:
+ self.assertNotIn("__config__", tomllib.load(f))
+ self.assertIn("borgbackup", upstream.stanzas())
+ self.assertEqual(upstream.stanzas("/nonexistent"), set())
+
+ def test_write_config(self):
+ keys = os.path.join(self.tmp, "keys.toml")
+ out = upstream.write_config(self.tmp, keys, self.stanzas)
+ with open(out, "rb") as f:
+ self.assertEqual(tomllib.load(f), {"__config__": {}, "x": {"source": "manual", "manual": "2.0"}})
+ open(keys, "w").close()
+ with open(upstream.write_config(self.tmp, keys, self.stanzas), "rb") as f:
+ self.assertEqual(tomllib.load(f)["__config__"], {"keyfile": keys})
+
+ def test_parse_log(self):
+ self.assertEqual(upstream.parse_log(LOG), ({"borgbackup": "1.4.5"}, {"bogus": "HTTPError(404)"}))
+
+ def test_state(self):
+ names = {"a", "b", "c"}
+ v = {"a": "2.0", "b": "1.0"}
+ self.assertEqual(upstream.state(row("z"), names, v, {}), "no stanza")
+ self.assertEqual(upstream.state(row("a"), names, None, {}), "not checked")
+ self.assertEqual(upstream.state(row("a"), names, v, {}), "outdated")
+ self.assertEqual(upstream.state(row("a", hint="2.0"), names, v, {}), "current")
+ self.assertEqual(upstream.state(row("b"), names, v, {}), "current") # .info version fallback
+ self.assertEqual(upstream.state(row("c"), names, v, {}), "nvchecker error")
+
+ def test_checker_runs_nvchecker(self):
+ bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(bindir)
+ with open(os.path.join(bindir, "nvchecker"), "w") as f:
+ f.write('#!/bin/sh\n[ "$1" = -c ] && [ -f "$2" ] && [ "$3 $4" = "--logger json" ] || exit 9\n'
+ 'echo \'{"name": "x", "version": "2.0", "level": "info"}\'\n')
+ os.chmod(os.path.join(bindir, "nvchecker"), 0o755)
+ with mock.patch.dict(os.environ, {"PATH": bindir + os.pathsep + os.environ["PATH"]}):
+ got = self.run_checker()
+ self.assertEqual(got, [({"x": "2.0"}, {}, "")])
+
+ def test_checker_missing_program(self):
+ with mock.patch.dict(os.environ, {"PATH": self.tmp}):
+ got = self.run_checker()
+ self.assertEqual(len(got), 1)
+ self.assertTrue(got[0][2])
+
+ def run_checker(self):
+ c = upstream.Checker()
+ self.addCleanup(c.deleteLater)
+ got = []
+ c.done.connect(lambda *a: got.append(a))
+ c.run(os.path.join(self.tmp, "nokeys.toml"), self.stanzas)
+ end = time.monotonic() + 5
+ while not got and time.monotonic() < end:
+ app.processEvents()
+ return got