From 5155e455d2ed909cf3bb5053eb7fd2cabb15f9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Wed, 16 Sep 2026 15:03:17 +0300 Subject: [PATCH] Add Gitea release updates and local Windows release pipeline --- .gitignore | 1 + README.md | 6 ++ app_version.py | 6 ++ docs/WINDOWS_RELEASE.md | 19 ++++ docs/releases/v1.1.0.md | 7 ++ gui_app.py | 155 ++++++++++++++++++++++++++- installer.iss | 4 +- modules/release_updater.py | 177 +++++++++++++++++++++++++++++++ scripts/publish_gitea_release.py | 113 ++++++++++++++++++++ scripts/push_main_release.ps1 | 85 +++++++++++++++ tests/release_updater_test.py | 124 ++++++++++++++++++++++ 11 files changed, 693 insertions(+), 4 deletions(-) create mode 100644 app_version.py create mode 100644 docs/WINDOWS_RELEASE.md create mode 100644 docs/releases/v1.1.0.md create mode 100644 modules/release_updater.py create mode 100644 scripts/publish_gitea_release.py create mode 100644 scripts/push_main_release.ps1 create mode 100644 tests/release_updater_test.py diff --git a/.gitignore b/.gitignore index b6168a8..f793e35 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ build/ develop-eggs/ dist/ +installer_output/ downloads/ eggs/ .eggs/ diff --git a/README.md b/README.md index e297d15..040f0a9 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Telegram-бот для автоматизации работы бухгалте - 📊 **Генерация Excel** - выгрузка в форматы УПД и ТОРГ-12 - 🤖 **Telegram интерфейс** - удобное управление через бота +## Windows-приложение и обновления + +Установщик Windows-приложения доступен в [релизах Gitea](https://git.robotail.pro/Robotail/BUH_bot/releases). После первой установки текущая версия видна в верхней части окна. Кнопка **«Проверить обновления»** сообщает о новом релизе, загружает установщик по выбору пользователя, проверяет его SHA-256 и запускает установку. Доступ к Gitea из локальной сети необходим для проверки и загрузки. + +Сборка следующих версий описана в [инструкции по релизам](docs/WINDOWS_RELEASE.md). + ## 📋 Требования - Python 3.10+ diff --git a/app_version.py b/app_version.py new file mode 100644 index 0000000..18a8df1 --- /dev/null +++ b/app_version.py @@ -0,0 +1,6 @@ +"""Version of the Windows GUI application and its update source.""" + +APP_VERSION = "1.1.0" +GITEA_REPOSITORY_URL = "https://git.robotail.pro/Robotail/BUH_bot" +INSTALLER_ASSET_NAME = "MoySklad_Generator_Setup.exe" +CHECKSUM_ASSET_NAME = INSTALLER_ASSET_NAME + ".sha256" diff --git a/docs/WINDOWS_RELEASE.md b/docs/WINDOWS_RELEASE.md new file mode 100644 index 0000000..0bddf83 --- /dev/null +++ b/docs/WINDOWS_RELEASE.md @@ -0,0 +1,19 @@ +# Релизы Windows-приложения + +## Для пользователей + +Первую версию установите из [релизов Gitea](https://git.robotail.pro/Robotail/BUH_bot/releases). В установленном приложении проверка нового релиза выполняется при запуске, а кнопка обновления находится в верхней строке. По нажатию можно прочитать описание релиза, скачать установщик и запустить его. Приложение сверяет SHA-256 с файлом `MoySklad_Generator_Setup.exe.sha256` из того же релиза и закрывается перед установкой. Windows может запросить права администратора. + +## Для выпуска новой версии + +1. Увеличьте `APP_VERSION` в `app_version.py` и `MyAppVersion` в `installer.iss` до одного номера. +2. Добавьте описание `docs/releases/v<номер>.md` и закоммитьте изменения в `main`. +3. На Windows-машине с Python 3.10 и Inno Setup 6 выполните одну команду: + +```powershell +.\scripts\push_main_release.ps1 +``` + +Скрипт проверит тесты и версии, соберёт EXE и установщик локально, отправит `main` и тег в Gitea и GitHub, затем создаст релиз в Gitea и загрузит установщик с SHA-256. Для работы нужен сохранённый Git-вход в Gitea. Повторный запуск безопасен, если публикация прервалась после отправки изменений. + +У Git нет хука после успешной отправки (`post-push`). Поэтому публикация выполняется одной командой вместо обычного `git push`; это гарантирует, что релиз создаётся только после проверок, сборки и успешной отправки. Windows runner и Gitea Actions не нужны. diff --git a/docs/releases/v1.1.0.md b/docs/releases/v1.1.0.md new file mode 100644 index 0000000..249a0b8 --- /dev/null +++ b/docs/releases/v1.1.0.md @@ -0,0 +1,7 @@ +## Обновление Windows-приложения + +- В верхней части окна теперь показана текущая версия и кнопка проверки обновлений. +- Приложение проверяет последний релиз Gitea при запуске. Установщик скачивается только после выбора пользователя и проверяется по SHA-256. +- Исправлено применение скидок по позициям в XML-выгрузке 1С: суммы и цены товаров и услуг соответствуют итоговой сумме отгрузки. + +Для первой установки и для перехода со старой версии без кнопки обновления скачайте `MoySklad_Generator_Setup.exe` из файлов релиза и запустите его. При последующих обновлениях используйте кнопку в приложении. diff --git a/gui_app.py b/gui_app.py index 4a2d0c8..8fe6907 100644 --- a/gui_app.py +++ b/gui_app.py @@ -24,8 +24,8 @@ from PyQt6.QtWidgets import ( QProgressBar, QGroupBox, QFormLayout, QComboBox, QSpinBox, QCheckBox, QDialog, QDialogButtonBox ) -from PyQt6.QtCore import Qt, QThread, pyqtSignal -from PyQt6.QtGui import QFont, QIcon, QPalette, QColor +from PyQt6.QtCore import Qt, QThread, QTimer, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices, QFont, QIcon, QPalette, QColor # Добавляем путь к modules sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'modules')) @@ -33,6 +33,10 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'modules')) from modules.moysklad_client import MoySkladClient from modules.validator import OrderValidator, ValidationLevel from modules.xml_generator import XML1CGenerator +from modules.release_updater import ( + ReleaseInfo, download_installer, get_latest_release, update_available, +) +from app_version import APP_VERSION from config import settings @@ -167,6 +171,24 @@ class WorkerThread(QThread): self.error.emit(str(e)) +class UpdateWorker(QThread): + """Run release checks and installer downloads outside the GUI thread.""" + + def __init__(self, operation, *args): + super().__init__() + self.operation = operation + self.args = args + self.result_value = None + self.error_message = None + + def run(self): + try: + self.result_value = self.operation(*self.args) + except Exception as error: + logger.exception("Ошибка обновления приложения") + self.error_message = str(error) + + class LoginDialog(QDialog): """Диалог авторизации""" def __init__(self, parent=None): @@ -211,16 +233,21 @@ class MainWindow(QMainWindow): self.current_orders: List[Dict] = [] self.selected_order_data: Optional[Dict] = None self.worker_thread: Optional[WorkerThread] = None + self.update_worker: Optional[UpdateWorker] = None + self.latest_release: Optional[ReleaseInfo] = None + self.manual_update_check = False + self.downloading_update = False self.current_page = 0 self.page_size = 25 self.total_orders = 0 self.init_ui() self.check_credentials() + QTimer.singleShot(0, self.check_for_updates) def init_ui(self): """Инициализация UI""" - self.setWindowTitle("МойСклад - Генератор документов") + self.setWindowTitle(f"МойСклад - Генератор документов {APP_VERSION}") self.setGeometry(100, 100, 1200, 800) # Установка иконки приложения @@ -243,6 +270,22 @@ class MainWindow(QMainWindow): status_layout.addWidget(self.status_label) status_layout.addStretch() + self.version_label = QLabel(f"Версия {APP_VERSION}") + status_layout.addWidget(self.version_label) + + self.update_label = QLabel("") + status_layout.addWidget(self.update_label) + + self.update_btn = QPushButton("Проверить обновления") + self.update_btn.setMinimumWidth(190) + self.update_btn.setStyleSheet( + "QPushButton { background-color: #2676c9; color: white; " + "font-weight: bold; padding: 7px 12px; border-radius: 4px; }" + "QPushButton:disabled { background-color: #777; }" + ) + self.update_btn.clicked.connect(self.on_update_clicked) + status_layout.addWidget(self.update_btn) + self.login_btn = QPushButton("Войти") self.login_btn.clicked.connect(self.show_login_dialog) status_layout.addWidget(self.login_btn) @@ -275,6 +318,112 @@ class MainWindow(QMainWindow): self.progress_bar.setVisible(False) main_layout.addWidget(self.progress_bar) + def check_for_updates(self, manual: bool = False): + """Check the latest published Gitea release without blocking the GUI.""" + if self.update_worker and self.update_worker.isRunning(): + return + self.manual_update_check = manual + self.downloading_update = False + self.update_btn.setEnabled(False) + self.update_label.setText("Проверка…") + self.update_worker = UpdateWorker(get_latest_release) + self.update_worker.finished.connect(self.on_check_finished) + self.update_worker.start() + + def on_check_finished(self): + worker = self.update_worker + if worker.error_message: + self.on_update_failed(worker.error_message) + else: + self.on_release_checked(worker.result_value) + + def on_release_checked(self, release: Optional[ReleaseInfo]): + self.latest_release = release + self.update_btn.setEnabled(True) + if release is None: + self.update_label.setText("Релизов пока нет") + self.update_btn.setText("Проверить обновления") + if self.manual_update_check: + QMessageBox.information(self, "Обновления", "Релизы пока не опубликованы.") + elif update_available(release): + self.update_label.setText(f"Доступна {release.tag_name}") + self.update_btn.setText(f"Обновить до {release.tag_name}") + if self.manual_update_check: + self.show_update_dialog(release) + else: + self.update_label.setText("Актуальная версия") + self.update_btn.setText("Проверить обновления") + if self.manual_update_check: + QMessageBox.information( + self, "Обновления", + f"Установлена версия {APP_VERSION}. Последний релиз: {release.tag_name}.", + ) + + def on_update_clicked(self): + if self.latest_release and update_available(self.latest_release): + self.show_update_dialog(self.latest_release) + else: + self.check_for_updates(manual=True) + + def show_update_dialog(self, release: ReleaseInfo): + dialog = QMessageBox(self) + dialog.setWindowTitle("Обновление приложения") + dialog.setTextFormat(Qt.TextFormat.PlainText) + dialog.setText(f"Доступна версия {release.tag_name} (установлена {APP_VERSION}).") + if release.notes: + dialog.setInformativeText(release.notes[:700]) + if len(release.notes) > 700: + dialog.setDetailedText(release.notes) + install_button = dialog.addButton( + "Скачать и установить", QMessageBox.ButtonRole.AcceptRole + ) + release_button = dialog.addButton( + "Открыть релиз", QMessageBox.ButtonRole.ActionRole + ) + dialog.addButton("Позже", QMessageBox.ButtonRole.RejectRole) + dialog.exec() + if dialog.clickedButton() == release_button: + QDesktopServices.openUrl(QUrl(release.page_url)) + elif dialog.clickedButton() == install_button: + self.download_update(release) + + def download_update(self, release: ReleaseInfo): + if self.update_worker and self.update_worker.isRunning(): + return + self.downloading_update = True + self.update_btn.setEnabled(False) + self.update_btn.setText("Загрузка…") + self.update_label.setText("Загрузка установщика…") + self.update_worker = UpdateWorker(download_installer, release) + self.update_worker.finished.connect(self.on_download_finished) + self.update_worker.start() + + def on_download_finished(self): + worker = self.update_worker + if worker.error_message: + self.on_update_failed(worker.error_message) + else: + self.on_installer_ready(worker.result_value) + + def on_installer_ready(self, installer_path: Path): + try: + os.startfile(str(installer_path)) + except OSError as error: + self.on_update_failed(f"Не удалось запустить установщик: {error}") + return + QApplication.instance().quit() + + def on_update_failed(self, message: str): + self.update_btn.setEnabled(True) + self.update_btn.setText( + f"Обновить до {self.latest_release.tag_name}" + if self.latest_release and update_available(self.latest_release) + else "Проверить обновления" + ) + self.update_label.setText("Не удалось загрузить" if self.downloading_update else "Нет связи с Gitea") + if self.manual_update_check or self.downloading_update: + QMessageBox.warning(self, "Обновление", message) + def create_search_tab(self) -> QWidget: """Создание вкладки поиска""" widget = QWidget() diff --git a/installer.iss b/installer.iss index ff5fd4e..867434d 100644 --- a/installer.iss +++ b/installer.iss @@ -2,7 +2,7 @@ ; Создает автономный установщик со всеми зависимостями #define MyAppName "МойСклад - Генератор документов" -#define MyAppVersion "1.0.0" +#define MyAppVersion "1.1.0" #define MyAppPublisher "BUH_bot" #define MyAppExeName "MoySkladGenerator.exe" @@ -25,6 +25,8 @@ DisableWelcomePage=no LicenseFile=LICENSE.txt InfoBeforeFile=INSTALL_INFO.txt PrivilegesRequired=admin +CloseApplications=yes +RestartApplications=no [Languages] Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" diff --git a/modules/release_updater.py b/modules/release_updater.py new file mode 100644 index 0000000..456b214 --- /dev/null +++ b/modules/release_updater.py @@ -0,0 +1,177 @@ +"""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) diff --git a/scripts/publish_gitea_release.py b/scripts/publish_gitea_release.py new file mode 100644 index 0000000..f0ce374 --- /dev/null +++ b/scripts/publish_gitea_release.py @@ -0,0 +1,113 @@ +"""Publish a verified Windows installer as a Gitea release. + +CI uses GITEA_TOKEN. For a manual local release, pass --use-git-credential. +""" + +import argparse +import hashlib +import os +import subprocess +import sys +from pathlib import Path +from urllib.parse import quote, urlsplit + +import requests + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from app_version import APP_VERSION, CHECKSUM_ASSET_NAME, GITEA_REPOSITORY_URL, INSTALLER_ASSET_NAME + + +API = "https://git.robotail.pro/api/v1/repos/Robotail/BUH_bot" + + +def session_for_gitea(use_git_credential: bool) -> requests.Session: + session = requests.Session() + token = os.environ.get("GITEA_TOKEN") + if token: + session.headers["Authorization"] = f"token {token}" + elif use_git_credential: + host = urlsplit(GITEA_REPOSITORY_URL).hostname + result = subprocess.run( + ["git", "credential", "fill"], + input=f"protocol=https\nhost={host}\n\n", + text=True, capture_output=True, check=True, + env={**os.environ, "GCM_INTERACTIVE": "never", "GIT_TERMINAL_PROMPT": "0"}, + ) + credentials = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) + session.auth = (credentials["username"], credentials["password"]) + else: + raise RuntimeError("Укажите GITEA_TOKEN или --use-git-credential") + return session + + +def request_json(session: requests.Session, method: str, path: str, **kwargs): + timeout = kwargs.pop("timeout", (10, 120)) + response = session.request(method, API + path, timeout=timeout, **kwargs) + if response.status_code >= 400: + raise RuntimeError(f"Gitea API: {method} {path} вернул HTTP {response.status_code}: {response.text[:300]}") + return response.json() + + +def publish(tag: str, body: str, use_git_credential: bool = False) -> str: + if tag != f"v{APP_VERSION}": + raise ValueError(f"Тег {tag} не совпадает с версией приложения v{APP_VERSION}") + installer = ROOT / "installer_output" / INSTALLER_ASSET_NAME + if not installer.is_file() or installer.stat().st_size == 0: + raise FileNotFoundError(installer) + digest_state = hashlib.sha256() + with installer.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest_state.update(chunk) + digest = digest_state.hexdigest() + checksum = installer.with_name(CHECKSUM_ASSET_NAME) + checksum.write_text(f"{digest} {INSTALLER_ASSET_NAME}\n", encoding="ascii") + + session = session_for_gitea(use_git_credential) + response = session.get(API + "/releases/tags/" + quote(tag), timeout=(10, 30)) + if response.status_code == 404: + release = request_json(session, "POST", "/releases", json={ + "tag_name": tag, + "target_commitish": "main", + "name": f"МойСклад — Генератор документов {tag}", + "body": body, + "draft": True, + "prerelease": False, + }) + else: + response.raise_for_status() + release = response.json() + + release_id = release["id"] + assets = {asset["name"]: asset for asset in release.get("assets", [])} + if not release.get("draft") and {INSTALLER_ASSET_NAME, CHECKSUM_ASSET_NAME} <= assets.keys(): + return release["html_url"] + for path in (installer, checksum): + existing = assets.get(path.name) + if existing: + if existing["size"] != path.stat().st_size: + raise RuntimeError(f"Файл {path.name} уже опубликован с другим размером") + continue + with path.open("rb") as handle: + request_json( + session, "POST", f"/releases/{release_id}/assets", + params={"name": path.name}, + files={"attachment": (path.name, handle, "application/octet-stream")}, + timeout=(10, 300), + ) + + if release.get("draft"): + release = request_json( + session, "PATCH", f"/releases/{release_id}", + json={"draft": False, "prerelease": False, "body": body}, + ) + return release["html_url"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag") + parser.add_argument("--body-file", type=Path, required=True) + parser.add_argument("--use-git-credential", action="store_true") + args = parser.parse_args() + print(publish(args.tag, args.body_file.read_text(encoding="utf-8"), args.use_git_credential)) diff --git a/scripts/push_main_release.ps1 b/scripts/push_main_release.ps1 new file mode 100644 index 0000000..1495bcb --- /dev/null +++ b/scripts/push_main_release.ps1 @@ -0,0 +1,85 @@ +# Local Windows release pipeline. Run after committing a new version on main. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-LastCommand($description) { + if ($LASTEXITCODE -ne 0) { throw "$description завершилась с кодом $LASTEXITCODE" } +} + +$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +Push-Location $projectRoot +try { + $branch = (& git branch --show-current).Trim() + Assert-LastCommand 'Проверка ветки' + if ($branch -ne 'main') { throw 'Публикация возможна только из ветки main' } + $changes = & git status --porcelain + Assert-LastCommand 'Проверка рабочего дерева' + if ($changes) { throw 'Перед выпуском закоммитьте все изменения' } + + $python = Join-Path $projectRoot '.venv_gui310\Scripts\python.exe' + if (-not (Test-Path $python)) { + & py -3.10 -m venv .venv_gui310 + Assert-LastCommand 'Создание окружения Python 3.10' + } + & $python -m pip install -r requirements_gui.txt + Assert-LastCommand 'Установка зависимостей' + + $version = (& $python -c 'from app_version import APP_VERSION; print(APP_VERSION)').Trim() + Assert-LastCommand 'Чтение версии' + $tag = "v$version" + $versionLine = '#define MyAppVersion "' + $version + '"' + if (-not (Select-String -Path installer.iss -SimpleMatch $versionLine)) { + throw 'Версия installer.iss не совпадает с app_version.py' + } + $notes = Join-Path $projectRoot "docs\releases\$tag.md" + if (-not (Test-Path $notes)) { throw "Нет описания релиза $notes" } + + $head = (& git rev-parse HEAD).Trim() + Assert-LastCommand 'Чтение коммита' + $existingTag = & git tag --list $tag + Assert-LastCommand 'Проверка тега' + if ($existingTag) { + $tagCommit = (& git rev-list -n 1 $tag).Trim() + Assert-LastCommand 'Проверка коммита тега' + if ($tagCommit -ne $head) { throw "Тег $tag уже указывает на другой коммит. Увеличьте версию." } + } + + & $python -m unittest discover -s tests -p '*_test.py' -v + Assert-LastCommand 'Тесты' + & $python -m PyInstaller gui_app.spec --clean --noconfirm + Assert-LastCommand 'Сборка EXE' + if (-not (Test-Path 'dist\MoySkladGenerator.exe')) { throw 'EXE не создан' } + + $iscc = Get-Command ISCC.exe -ErrorAction SilentlyContinue + if ($iscc) { $compiler = $iscc.Source } + elseif (Test-Path "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe") { + $compiler = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + } + else { $compiler = "$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe" } + if (-not (Test-Path $compiler)) { throw 'Inno Setup 6 (ISCC.exe) не найден' } + & $compiler installer.iss + Assert-LastCommand 'Сборка установщика' + if (-not (Test-Path 'installer_output\MoySklad_Generator_Setup.exe')) { + throw 'Установщик не создан' + } + + & git push gitea main + Assert-LastCommand 'Отправка main в Gitea' + & git push origin main + Assert-LastCommand 'Отправка main в GitHub' + + if (-not $existingTag) { + & git tag -a $tag -m "Release $tag" + Assert-LastCommand 'Создание тега' + } + & git push gitea $tag + Assert-LastCommand 'Отправка тега в Gitea' + & git push origin $tag + Assert-LastCommand 'Отправка тега в GitHub' + + & $python scripts\publish_gitea_release.py $tag --body-file $notes --use-git-credential + Assert-LastCommand 'Публикация релиза в Gitea' +} +finally { + Pop-Location +} diff --git a/tests/release_updater_test.py b/tests/release_updater_test.py new file mode 100644 index 0000000..7656ff4 --- /dev/null +++ b/tests/release_updater_test.py @@ -0,0 +1,124 @@ +"""Release metadata and installer integrity checks.""" + +import hashlib +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from packaging.version import Version + +from app_version import CHECKSUM_ASSET_NAME, INSTALLER_ASSET_NAME +from modules.release_updater import ( + ReleaseInfo, + UpdateError, + download_installer, + get_latest_release, + update_available, +) + + +HOST = "https://git.robotail.pro" + + +class FakeResponse: + def __init__(self, *, status=200, url=HOST, data=None, text="", chunks=()): + self.status_code = status + self.url = url + self.data = data + self.text = text + self.chunks = chunks + + def raise_for_status(self): + if self.status_code >= 400: + raise ValueError(f"HTTP {self.status_code}") + + def json(self): + return self.data + + def iter_content(self, chunk_size): + return iter(self.chunks) + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + +class FakeSession: + def __init__(self, responses): + self.responses = iter(responses) + + def get(self, *_args, **_kwargs): + return next(self.responses) + + +def release_data(installer_url=HOST + "/download/setup"): + return { + "tag_name": "v1.2.0", + "body": "Исправления и новые возможности", + "html_url": HOST + "/Robotail/BUH_bot/releases/tag/v1.2.0", + "assets": [ + {"name": INSTALLER_ASSET_NAME, "browser_download_url": installer_url, "size": 7}, + {"name": CHECKSUM_ASSET_NAME, "browser_download_url": HOST + "/download/sha", "size": 90}, + ], + } + + +class ReleaseUpdaterTests(unittest.TestCase): + def test_no_published_release(self): + self.assertIsNone(get_latest_release(FakeSession([FakeResponse(status=404)]))) + + def test_newer_release_with_assets(self): + release = get_latest_release(FakeSession([FakeResponse(data=release_data())])) + self.assertEqual(release.version, Version("1.2.0")) + self.assertTrue(update_available(release)) + + def test_rejects_untrusted_installer_host(self): + with self.assertRaises(UpdateError): + get_latest_release(FakeSession([FakeResponse(data=release_data("https://evil.example/setup"))])) + + def test_rejects_missing_checksum(self): + data = release_data() + data["assets"].pop() + with self.assertRaises(UpdateError): + get_latest_release(FakeSession([FakeResponse(data=data)])) + + def test_download_and_verify(self): + payload = b"installer" + digest = hashlib.sha256(payload).hexdigest() + release = ReleaseInfo( + "v1.2.0", Version("1.2.0"), "", HOST + "/release", + HOST + "/download/setup", HOST + "/download/sha", len(payload), + ) + session = FakeSession([ + FakeResponse(url=release.checksum_url, text=f"{digest} {INSTALLER_ASSET_NAME}"), + FakeResponse(url=release.installer_url, chunks=[payload[:3], payload[3:]]), + ]) + with tempfile.TemporaryDirectory() as directory, patch( + "modules.release_updater.tempfile.gettempdir", return_value=directory + ): + target = download_installer(release, session) + self.assertEqual(target.read_bytes(), payload) + + def test_rejects_corrupt_installer(self): + payload = b"corrupt" + release = ReleaseInfo( + "v1.2.0", Version("1.2.0"), "", HOST + "/release", + HOST + "/download/setup", HOST + "/download/sha", len(payload), + ) + session = FakeSession([ + FakeResponse(url=release.checksum_url, text="0" * 64), + FakeResponse(url=release.installer_url, chunks=[payload]), + ]) + with tempfile.TemporaryDirectory() as directory, patch( + "modules.release_updater.tempfile.gettempdir", return_value=directory + ): + with self.assertRaises(UpdateError): + download_installer(release, session) + self.assertFalse((Path(directory) / "MoySkladGenerator" / "v1.2.0" / INSTALLER_ASSET_NAME).exists()) + + +if __name__ == "__main__": + unittest.main()