Releases on Gitea, one-line installer and auto-update (1.1.0)

- .github/workflows/release.yml: a tag vX.Y.Z runs the tests, publishes
  daynivo-kiosk-os-X.Y.Z.tar.gz (+ .sha256) and install-online.sh as a
  Gitea release, then builds the SD image with pi-gen and attaches it.
- install-online.sh: curl | sudo bash installer for the latest release.
- voxip-kiosk-updater: checks the latest release, verifies and unpacks a
  newer one under /opt/daynivo-kiosk-os and runs its update.sh. A daily
  timer checks around 03:00 and installs when auto_update is on.
- Web interface: Systeem -> Software-updates with the auto-update switch,
  check now and install now (helper verbs update-check / update-install).
- CI checks out with plain git, so it runs on Gitea's act_runner too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-05 19:30:34 +01:00
1 parent 33ef898fcc
commit f8303754f7
25 files changed
+899 -20

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import hashlib
import io
import json
import subprocess
import tarfile
import pytest
from voxip_kiosk import updater as u
from voxip_kiosk.config import save_config
API = u.RELEASE_API
BASE = "https://git.example.nl/rothman/daynivo-kiosk-os/releases/download"
def make_tarball(version, files=None, top=None):
"""A release archive like `git archive --prefix=daynivo-kiosk-os-<v>/` makes."""
top = top or f"daynivo-kiosk-os-{version}"
files = files or {"VERSION": f"{version}\n", "update.sh": "#!/bin/sh\n"}
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for name, content in files.items():
data = content.encode()
info = tarfile.TarInfo(f"{top}/{name}" if not name.startswith(("/", "..")) else name)
info.size = len(data)
info.mode = 0o755
tar.addfile(info, io.BytesIO(data))
return buf.getvalue()
def release_json(version, assets=True):
names = [f"daynivo-kiosk-os-{version}.tar.gz", f"daynivo-kiosk-os-{version}.tar.gz.sha256"] if assets else []
return {
"tag_name": f"v{version}",
"html_url": f"https://git.example.nl/rothman/daynivo-kiosk-os/releases/tag/v{version}",
"assets": [{"name": n, "browser_download_url": f"{BASE}/v{version}/{n}"} for n in names],
}
class Server:
"""Stands in for the Gitea API and asset downloads."""
def __init__(self, version="1.2.0", tarball=None, checksum=None):
self.version = version
self.tarball = tarball if tarball is not None else make_tarball(version)
self.checksum = checksum or hashlib.sha256(self.tarball).hexdigest()
self.release = release_json(version)
self.down = False
def __call__(self, url, limit=u.MAX_DOWNLOAD, timeout=30):
if self.down:
raise u.UpdateError("Downloaden mislukt: geen verbinding")
if url == API:
return json.dumps(self.release).encode()
if url.endswith(".tar.gz"):
return self.tarball
if url.endswith(".sha256"):
return f"{self.checksum} daynivo-kiosk-os-{self.version}.tar.gz\n".encode()
raise AssertionError(url)
class Run:
def __init__(self, returncode=0, stderr=""):
self.calls = []
self.returncode = returncode
self.stderr = stderr
def __call__(self, args, **kwargs):
self.calls.append(args)
return subprocess.CompletedProcess(args, self.returncode, stdout="", stderr=self.stderr)
@pytest.fixture
def make(paths, tmp_path):
(paths.share_dir / "VERSION").write_text("1.1.0\n")
def factory(server=None, run=None):
server = server or Server()
run = run or Run()
updater = u.Updater(paths, get=server, run=run, release_dir=tmp_path / "opt", cache_dir=tmp_path / "cache",
now=lambda: 1_800_000_000)
return updater, server, run
return factory
def test_versions():
assert u.parse_version("v1.2.3") == (1, 2, 3)
assert u.parse_version("1.2") is None
assert u.is_newer("1.10.0", "1.9.9")
assert not u.is_newer("1.1.0", "1.1.0")
assert not u.is_newer("1.0.9", "1.1.0")
assert u.is_newer("1.1.0", "1.0.0-dev")
def test_release_needs_both_assets():
with pytest.raises(u.UpdateError, match="mist"):
u.parse_release(release_json("1.2.0", assets=False))
with pytest.raises(u.UpdateError, match="versienummer"):
u.parse_release({**release_json("1.2.0"), "tag_name": "nightly"})
def test_check_records_an_available_update(make, paths):
updater, _, run = make()
state, release = updater.check()
assert release.version == "1.2.0"
assert state["available"] is True and state["current"] == "1.1.0" and state["latest"] == "1.2.0"
assert json.loads(paths.update_state_file.read_text())["checked_at"] == 1_800_000_000
assert run.calls == []
def test_check_when_up_to_date_or_offline(make):
updater, server, _ = make(Server(version="1.1.0"))
state, release = updater.check()
assert release is None and state["available"] is False
server.down = True
state, release = updater.check()
assert release is None and "geen verbinding" in state["check_error"]
assert state["latest"] == "1.1.0" # the last known answer stays visible
def test_install_unpacks_and_runs_update_sh(make, tmp_path, paths):
updater, _, run = make()
_, release = updater.check()
state = updater.install(release)
assert state["status"] == "done" and state["current"] == "1.2.0" and state["available"] is False
folder = tmp_path / "opt" / "1.2.0"
assert run.calls == [[str(folder / "update.sh"), "--no-pull"]]
assert (folder / "VERSION").read_text().strip() == "1.2.0"
def test_install_refuses_a_bad_checksum(make, tmp_path):
updater, _, run = make(Server(checksum="0" * 64))
_, release = updater.check()
state = updater.install(release)
assert state["status"] == "failed" and "Checksum" in state["install_error"]
assert run.calls == []
assert not (tmp_path / "opt" / "1.2.0").exists()
def test_install_refuses_paths_outside_the_release(make):
tarball = make_tarball("1.2.0", {"VERSION": "1.2.0\n", "../../etc/evil": "x"})
updater, _, run = make(Server(tarball=tarball))
_, release = updater.check()
state = updater.install(release)
assert state["status"] == "failed" and "Onveilig" in state["install_error"]
assert run.calls == []
def test_install_refuses_a_mislabelled_release(make):
updater, _, run = make(Server(tarball=make_tarball("1.2.0", {"VERSION": "9.0.0\n"})))
_, release = updater.check()
assert updater.install(release)["status"] == "failed"
assert run.calls == []
def test_install_reports_a_failing_installer(make):
updater, _, _ = make(run=Run(returncode=1, stderr="apt-get: kon niet verbinden"))
_, release = updater.check()
state = updater.install(release)
assert state["status"] == "failed" and "apt-get" in state["install_error"]
def test_never_installs_an_older_version(make):
updater, _, _ = make()
with pytest.raises(u.UpdateError):
updater.install(u.Release("1.0.0", f"{BASE}/a.tar.gz", f"{BASE}/a.sha256", ""))
def test_keeps_only_the_newest_releases(make, tmp_path):
updater, _, _ = make()
for old in ("1.0.0", "1.0.1", "1.1.0"):
(tmp_path / "opt" / old).mkdir(parents=True)
_, release = updater.check()
updater.install(release)
assert sorted(p.name for p in (tmp_path / "opt").iterdir()) == ["1.1.0", "1.2.0"]
def test_auto_mode_installs_only_when_switched_on(make, paths):
updater, _, run = make()
assert u.main(["--auto"], updater=updater) == 0
assert run.calls == []
save_config(paths.config_file, {"auto_update": True})
(paths.health_run_dir / "maintenance").touch()
assert u.main(["--auto"], updater=updater) == 0
assert run.calls == []
(paths.health_run_dir / "maintenance").unlink()
assert u.main(["--auto"], updater=updater) == 0
assert len(run.calls) == 1
def test_check_mode_never_installs(make):
updater, _, run = make()
assert u.main(["--check"], updater=updater) == 0
assert run.calls == []