BUH_bot/modules/release_updater.py
Игорь Надеин 5155e455d2
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Add Gitea release updates and local Windows release pipeline
2026-09-16 15:03:17 +03:00

178 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Check public Gitea releases and securely stage the Windows installer."""
import hashlib
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit
import requests
from packaging.version import InvalidVersion, Version
from app_version import (
APP_VERSION,
CHECKSUM_ASSET_NAME,
GITEA_REPOSITORY_URL,
INSTALLER_ASSET_NAME,
)
RELEASE_API_URL = (
"https://git.robotail.pro/api/v1/repos/Robotail/BUH_bot/releases/latest"
)
REQUEST_TIMEOUT = (5, 30)
class UpdateError(Exception):
"""The release cannot be used to update this application."""
@dataclass(frozen=True)
class ReleaseInfo:
tag_name: str
version: Version
notes: str
page_url: str
installer_url: str
checksum_url: str
installer_size: int
def _trusted_url(url: str) -> str:
"""Allow downloads only from the repository's HTTPS host."""
try:
parsed = urlsplit(url)
repository_host = urlsplit(GITEA_REPOSITORY_URL).hostname
trusted = (
parsed.scheme == "https"
and parsed.hostname == repository_host
and parsed.port in (None, 443)
and parsed.username is None
and parsed.password is None
)
except (TypeError, ValueError):
trusted = False
if not trusted:
raise UpdateError("Релиз содержит ссылку на неизвестный сервер")
return url
def _version_from_tag(tag_name: str) -> Version:
try:
return Version(tag_name.removeprefix("v"))
except InvalidVersion as error:
raise UpdateError(f"Некорректный номер релиза: {tag_name}") from error
def get_latest_release(session=requests) -> ReleaseInfo | None:
"""Return the latest published stable Gitea release, if one exists."""
try:
response = session.get(RELEASE_API_URL, timeout=REQUEST_TIMEOUT)
if response.status_code == 404:
return None
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError) as error:
raise UpdateError(f"Не удалось получить список релизов: {error}") from error
if not isinstance(data, dict):
raise UpdateError("Gitea вернула некорректные данные релиза")
tag_name = data.get("tag_name")
assets = data.get("assets")
if not isinstance(tag_name, str) or not isinstance(assets, list):
raise UpdateError("В релизе нет номера версии или списка файлов")
installer = next(
(asset for asset in assets if isinstance(asset, dict) and asset.get("name") == INSTALLER_ASSET_NAME),
None,
)
checksum = next(
(asset for asset in assets if isinstance(asset, dict) and asset.get("name") == CHECKSUM_ASSET_NAME),
None,
)
if installer is None or checksum is None:
raise UpdateError("В релизе нет установщика или файла контрольной суммы")
try:
installer_url = _trusted_url(installer["browser_download_url"])
checksum_url = _trusted_url(checksum["browser_download_url"])
page_url = _trusted_url(data["html_url"])
installer_size = int(installer["size"])
except (KeyError, TypeError, ValueError) as error:
raise UpdateError("В релизе указаны некорректные файлы") from error
if installer_size <= 0:
raise UpdateError("В релизе указан пустой установщик")
return ReleaseInfo(
tag_name=tag_name,
version=_version_from_tag(tag_name),
notes=str(data.get("body") or ""),
page_url=page_url,
installer_url=installer_url,
checksum_url=checksum_url,
installer_size=installer_size,
)
def update_available(release: ReleaseInfo) -> bool:
return release.version > Version(APP_VERSION)
def _expected_checksum(release: ReleaseInfo, session) -> str:
try:
response = session.get(release.checksum_url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
_trusted_url(response.url)
except requests.RequestException as error:
raise UpdateError(f"Не удалось получить контрольную сумму: {error}") from error
checksum_line = response.text.strip()
match = re.fullmatch(
rf"([0-9a-fA-F]{{64}})(?:\s+\*?{re.escape(INSTALLER_ASSET_NAME)})?",
checksum_line,
)
if not match:
raise UpdateError("Файл контрольной суммы имеет неверный формат")
return match.group(1).lower()
def download_installer(release: ReleaseInfo, session=requests) -> Path:
"""Download and verify the installer before it can be launched."""
expected_hash = _expected_checksum(release, session)
target_dir = Path(tempfile.gettempdir()) / "MoySkladGenerator" / release.tag_name
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / INSTALLER_ASSET_NAME
if target.is_file() and hashlib.sha256(target.read_bytes()).hexdigest() == expected_hash:
return target
temporary_path = None
try:
with session.get(release.installer_url, stream=True, timeout=REQUEST_TIMEOUT) as response:
response.raise_for_status()
_trusted_url(response.url)
with tempfile.NamedTemporaryFile(
dir=target_dir, prefix="installer-", suffix=".part", delete=False
) as temporary:
temporary_path = Path(temporary.name)
digest = hashlib.sha256()
downloaded = 0
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
temporary.write(chunk)
digest.update(chunk)
downloaded += len(chunk)
if downloaded != release.installer_size or digest.hexdigest() != expected_hash:
raise UpdateError("Загруженный установщик повреждён или не прошёл проверку SHA-256")
os.replace(temporary_path, target)
return target
except requests.RequestException as error:
raise UpdateError(f"Не удалось загрузить установщик: {error}") from error
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)