Add Gitea release updates and local Windows release pipeline
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
This commit is contained in:
parent
2c866e2ada
commit
5155e455d2
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ __pycache__/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
installer_output/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
@ -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
6
app_version.py
Normal 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
19
docs/WINDOWS_RELEASE.md
Normal 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
7
docs/releases/v1.1.0.md
Normal file
@ -0,0 +1,7 @@
|
||||
## Обновление Windows-приложения
|
||||
|
||||
- В верхней части окна теперь показана текущая версия и кнопка проверки обновлений.
|
||||
- Приложение проверяет последний релиз Gitea при запуске. Установщик скачивается только после выбора пользователя и проверяется по SHA-256.
|
||||
- Исправлено применение скидок по позициям в XML-выгрузке 1С: суммы и цены товаров и услуг соответствуют итоговой сумме отгрузки.
|
||||
|
||||
Для первой установки и для перехода со старой версии без кнопки обновления скачайте `MoySklad_Generator_Setup.exe` из файлов релиза и запустите его. При последующих обновлениях используйте кнопку в приложении.
|
||||
155
gui_app.py
155
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()
|
||||
|
||||
@ -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
177
modules/release_updater.py
Normal 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)
|
||||
113
scripts/publish_gitea_release.py
Normal file
113
scripts/publish_gitea_release.py
Normal 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))
|
||||
85
scripts/push_main_release.ps1
Normal file
85
scripts/push_main_release.ps1
Normal 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
|
||||
}
|
||||
124
tests/release_updater_test.py
Normal file
124
tests/release_updater_test.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user