324 lines
13 KiB
Python
324 lines
13 KiB
Python
"""Self-update from the Gitea releases page.
|
|
|
|
Polls `<UPDATE_REPO_API>/releases/latest` for a tag newer than
|
|
``bolt_pet.__version__`` and, if there is one, moves the checkout to that tag
|
|
and restarts the pet. The install is expected to be a git clone (which is how
|
|
it's deployed), so "download the update" is just `git fetch` + `git checkout`
|
|
— atomic, and the previous ref is one command away if anything goes wrong.
|
|
|
|
Safety rules, in the order they're enforced:
|
|
|
|
1. **A dirty working tree is never touched.** Local edits are skipped over,
|
|
not stashed — the pet silently discarding your work-in-progress would be
|
|
far worse than running an old version.
|
|
2. **Everything after checkout is guarded.** Dependency install and an import
|
|
smoke test both run before the restart; if either fails, the checkout is
|
|
rolled back to the exact ref that was live before (branch name if we were
|
|
on one, otherwise the commit) and the deps reinstalled from it.
|
|
3. **The restart only happens once the new code imports.** So a broken
|
|
release costs you a rollback and a log line, not a pet that won't start.
|
|
|
|
The git side goes through an injectable *run* callable — ``(args) ->
|
|
(returncode, output)`` — so the whole apply/rollback dance is unit-tested
|
|
against a fake git rather than a real repo. Version comparison and release
|
|
parsing are pure functions for the same reason.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable, Optional, Tuple
|
|
|
|
import requests
|
|
|
|
from . import config
|
|
|
|
# (returncode, combined stdout+stderr)
|
|
GitResult = Tuple[int, str]
|
|
GitRunner = Callable[[list], GitResult]
|
|
|
|
_GIT_TIMEOUT_SECONDS = 300
|
|
|
|
|
|
class UpdateError(Exception):
|
|
"""Raised when an update can't be applied. If it's raised *after* the
|
|
checkout moved, the rollback has already run."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Release:
|
|
tag: str
|
|
name: str
|
|
body: str
|
|
prerelease: bool
|
|
|
|
|
|
# ── pure logic ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def parse_version(tag: str) -> tuple:
|
|
"""``"v1.2.3"`` -> ``(1, 2, 3)``. Leading "v" optional; a trailing
|
|
suffix ends the parse (``"1.2.3-beta1"`` -> ``(1, 2, 3)``), so a
|
|
prerelease of a version compares equal to it rather than sorting
|
|
randomly. Junk parses to ``()``, which is never newer than anything."""
|
|
parts: list[int] = []
|
|
for chunk in (tag or "").strip().lstrip("vV").split("."):
|
|
digits = ""
|
|
for char in chunk:
|
|
if not char.isdigit():
|
|
break
|
|
digits += char
|
|
if not digits:
|
|
break
|
|
parts.append(int(digits))
|
|
return tuple(parts)
|
|
|
|
|
|
def is_newer(candidate: str, current: str) -> bool:
|
|
"""True if *candidate* is a strictly newer version than *current*.
|
|
Compares zero-padded, so 1.2 == 1.2.0 and 1.2.1 > 1.2."""
|
|
new, old = parse_version(candidate), parse_version(current)
|
|
if not new:
|
|
return False
|
|
width = max(len(new), len(old))
|
|
return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old))
|
|
|
|
|
|
def release_from_payload(payload: dict) -> Optional[Release]:
|
|
"""Gitea's release JSON -> Release, or None if it's a draft or has no
|
|
tag. /releases/latest already excludes drafts and prereleases, but the
|
|
same parser is used for the full list."""
|
|
if not isinstance(payload, dict) or payload.get("draft"):
|
|
return None
|
|
tag = str(payload.get("tag_name") or "").strip()
|
|
if not tag:
|
|
return None
|
|
return Release(
|
|
tag=tag,
|
|
name=str(payload.get("name") or tag),
|
|
body=str(payload.get("body") or ""),
|
|
prerelease=bool(payload.get("prerelease")),
|
|
)
|
|
|
|
|
|
# ── talking to Gitea ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def fetch_latest_release(api_url: str = None, token: str = None, timeout: float = 15.0) -> Optional[Release]:
|
|
"""Newest published release, or None if the repo has no releases yet
|
|
(a fresh repo 404s here, which is not an error worth logging every hour).
|
|
Raises UpdateError if the server is unreachable or answers with junk."""
|
|
api_url = (api_url if api_url is not None else config.UPDATE_REPO_API).rstrip("/")
|
|
if not api_url:
|
|
raise UpdateError("UPDATE_REPO_API is not set")
|
|
token = config.UPDATE_TOKEN if token is None else token
|
|
headers = {"Authorization": f"token {token}"} if token else {}
|
|
try:
|
|
response = requests.get(f"{api_url}/releases/latest", headers=headers, timeout=timeout)
|
|
except Exception as exc:
|
|
raise UpdateError(f"couldn't reach the releases API: {exc}") from exc
|
|
if response.status_code == 404:
|
|
return None
|
|
try:
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
except Exception as exc:
|
|
raise UpdateError(f"bad response from the releases API: {exc}") from exc
|
|
return release_from_payload(payload)
|
|
|
|
|
|
def check_for_update(current_version: str = None, **kwargs) -> Optional[Release]:
|
|
"""The whole "is there anything new?" question in one call. Returns the
|
|
Release to move to, or None if we're already current."""
|
|
from . import __version__
|
|
|
|
current = __version__ if current_version is None else current_version
|
|
release = fetch_latest_release(**kwargs)
|
|
if release is None or not is_newer(release.tag, current):
|
|
return None
|
|
return release
|
|
|
|
|
|
# ── git ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def git_runner(repo: Path = None) -> GitRunner:
|
|
repo = Path(repo or config.HERE)
|
|
|
|
def run(args: list) -> GitResult:
|
|
try:
|
|
completed = subprocess.run(
|
|
["git", *args], cwd=str(repo), capture_output=True,
|
|
text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception as exc:
|
|
return 1, f"git {' '.join(args)} failed to start: {exc}"
|
|
return completed.returncode, ((completed.stdout or "") + (completed.stderr or "")).strip()
|
|
|
|
return run
|
|
|
|
|
|
def is_git_clone(run: GitRunner) -> bool:
|
|
return run(["rev-parse", "--git-dir"])[0] == 0
|
|
|
|
|
|
def working_tree_dirty(run: GitRunner) -> bool:
|
|
code, output = run(["status", "--porcelain"])
|
|
return code != 0 or bool(output.strip())
|
|
|
|
|
|
def current_ref(run: GitRunner) -> str:
|
|
"""The branch name if we're on one, else the commit SHA — i.e. whatever
|
|
`git checkout` needs to put things back exactly as they were."""
|
|
code, output = run(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
|
if code == 0 and output.strip():
|
|
return output.strip()
|
|
code, output = run(["rev-parse", "HEAD"])
|
|
if code != 0 or not output.strip():
|
|
raise UpdateError("couldn't work out the current git ref")
|
|
return output.strip()
|
|
|
|
|
|
def already_at_tag(run: GitRunner, tag: str) -> bool:
|
|
"""True if HEAD is already the commit *tag* points at.
|
|
|
|
Guards the loop you get when a release is cut without bumping
|
|
__version__ in the tagged commit: the checkout succeeds (it's a no-op),
|
|
the pet restarts, reads the same old __version__, sees the same "newer"
|
|
tag, and does it again — a restart every check, forever."""
|
|
code, head = run(["rev-parse", "HEAD"])
|
|
if code != 0 or not head.strip():
|
|
return False
|
|
code, target = run(["rev-parse", f"tags/{tag}^{{commit}}"])
|
|
if code != 0 or not target.strip():
|
|
return False # tag isn't known locally yet, so we're certainly not on it
|
|
return head.strip() == target.strip()
|
|
|
|
|
|
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
|
|
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
|
|
return code == 0 and bool(output.strip())
|
|
|
|
|
|
def _install_deps(repo: Path) -> None:
|
|
completed = subprocess.run(
|
|
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
|
|
cwd=str(repo), capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS,
|
|
)
|
|
if completed.returncode != 0:
|
|
raise UpdateError(f"pip install failed: {(completed.stderr or '')[-500:]}")
|
|
|
|
|
|
def _smoke_test(repo: Path) -> None:
|
|
"""Import the freshly checked-out package in a *subprocess* — this one
|
|
still has the old modules loaded, so importing here would prove nothing.
|
|
Catches the common broken release (syntax error, missing dependency)
|
|
before we hand the session over to it."""
|
|
completed = subprocess.run(
|
|
[sys.executable, "-c", "import bolt_pet; import bolt_pet.controller"],
|
|
cwd=str(repo), capture_output=True, text=True, timeout=120,
|
|
)
|
|
if completed.returncode != 0:
|
|
raise UpdateError(f"the new version failed to import: {(completed.stderr or '')[-500:]}")
|
|
|
|
|
|
def apply_update(
|
|
tag: str,
|
|
run: GitRunner = None,
|
|
repo: Path = None,
|
|
on_log: Callable[[str], None] = lambda _msg: None,
|
|
install_deps: bool = None,
|
|
verify: Callable[[Path], None] = None,
|
|
) -> str:
|
|
"""Move the checkout to *tag*, rolling back to where it was if anything
|
|
downstream of the checkout fails. Returns the ref we came from (handy for
|
|
logging / a manual `git checkout` back). Raises UpdateError otherwise."""
|
|
repo = Path(repo or config.HERE)
|
|
run = run or git_runner(repo)
|
|
install_deps = config.UPDATE_INSTALL_DEPS if install_deps is None else install_deps
|
|
verify = _smoke_test if verify is None else verify
|
|
|
|
if not is_git_clone(run):
|
|
raise UpdateError("not a git clone — auto-update only works on a git checkout")
|
|
if working_tree_dirty(run):
|
|
raise UpdateError("working tree has local changes — skipping (nothing was touched)")
|
|
|
|
previous = current_ref(run)
|
|
code, output = run(["fetch", "--tags", "--prune", config.UPDATE_GIT_REMOTE])
|
|
if code != 0:
|
|
raise UpdateError(f"git fetch failed: {output}")
|
|
|
|
if already_at_tag(run, tag):
|
|
from . import __version__
|
|
|
|
raise UpdateError(
|
|
f"already checked out {tag}, but __version__ still reads {__version__} — "
|
|
f"bump it in the tagged commit, or every check re-applies the same release"
|
|
)
|
|
|
|
code, output = run(["checkout", "--force", f"tags/{tag}"])
|
|
if code != 0:
|
|
raise UpdateError(f"git checkout {tag} failed: {output}")
|
|
on_log(f"Checked out {tag} (was {previous}).")
|
|
|
|
# Past this point every failure has to put the checkout back.
|
|
try:
|
|
if install_deps and _requirements_changed(run, previous, f"tags/{tag}"):
|
|
on_log("requirements.txt changed — installing.")
|
|
_install_deps(repo)
|
|
verify(repo)
|
|
except UpdateError as exc:
|
|
_rollback(run, previous, repo, on_log, install_deps)
|
|
raise UpdateError(f"{exc} — rolled back to {previous}") from exc
|
|
except Exception as exc: # a verify() that blows up is still a failed update
|
|
_rollback(run, previous, repo, on_log, install_deps)
|
|
raise UpdateError(f"update failed ({exc}) — rolled back to {previous}") from exc
|
|
|
|
return previous
|
|
|
|
|
|
def _rollback(
|
|
run: GitRunner,
|
|
previous: str,
|
|
repo: Path,
|
|
on_log: Callable[[str], None],
|
|
install_deps: bool,
|
|
) -> None:
|
|
"""Best-effort return to *previous*. Never raises — it's already running
|
|
inside a failure path, and the caller's UpdateError is the thing worth
|
|
surfacing. A rollback that itself fails gets its own loud log line,
|
|
because that's the one case needing a human."""
|
|
code, output = run(["checkout", "--force", previous])
|
|
if code != 0:
|
|
on_log(f"ROLLBACK FAILED — the checkout is stranded. Run: git checkout {previous} ({output})")
|
|
return
|
|
on_log(f"Rolled back to {previous}.")
|
|
if install_deps:
|
|
try:
|
|
_install_deps(repo)
|
|
except Exception as exc:
|
|
on_log(f"Rolled back, but reinstalling the old requirements failed: {exc}")
|
|
|
|
|
|
# ── restart ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def restart() -> None:
|
|
"""Replace this process with a fresh `python -m bolt_pet`.
|
|
|
|
execv rather than spawn-and-exit so there's no window with two pets
|
|
holding the same mic, and no orphan if the parent dies first. Never
|
|
returns when it works; callers should have shut the Qt app and released
|
|
the audio device before calling it.
|
|
|
|
chdir first because `-m bolt_pet` resolves the package from the working
|
|
directory: the pet may well have been launched from somewhere else
|
|
(autostart entry, run.sh invoked by path), and the new process has to
|
|
land on the checkout the update was just applied to."""
|
|
os.chdir(str(config.HERE))
|
|
os.execv(sys.executable, [sys.executable, "-m", "bolt_pet"])
|