Add Gitea release updates and local Windows release pipeline
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled

This commit is contained in:
admin i 2026-09-16 15:03:17 +03:00
parent 2c866e2ada
commit 5155e455d2
11 changed files with 693 additions and 4 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@ __pycache__/
build/
develop-eggs/
dist/
installer_output/
downloads/
eggs/
.eggs/

View File

@ -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+

6
app_version.py Normal file
View File

@ -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"

19
docs/WINDOWS_RELEASE.md Normal file
View File

@ -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 не нужны.

7
docs/releases/v1.1.0.md Normal file
View File

@ -0,0 +1,7 @@
## Обновление Windows-приложения
- В верхней части окна теперь показана текущая версия и кнопка проверки обновлений.
- Приложение проверяет последний релиз Gitea при запуске. Установщик скачивается только после выбора пользователя и проверяется по SHA-256.
- Исправлено применение скидок по позициям в XML-выгрузке 1С: суммы и цены товаров и услуг соответствуют итоговой сумме отгрузки.
Для первой установки и для перехода со старой версии без кнопки обновления скачайте `MoySklad_Generator_Setup.exe` из файлов релиза и запустите его. При последующих обновлениях используйте кнопку в приложении.

View File

@ -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()

View File

@ -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"

177
modules/release_updater.py Normal file
View File

@ -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)

View File

@ -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))

View File

@ -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
}

View File

@ -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()