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