#!/usr/bin/env python3 """Publish a SaraSuite release: build the AppImage, create the Gitea release v (from Core.py) with it attached, and copy it to the Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py). Packaging/Release.py # after bumping AppVersion in Core.py Packaging/Release.py --notes "What changed" Everything must be committed and pushed first, so the release's tag points at code that's actually on Gitea. The Gitea token comes from git's own credential store (the same one `git push` uses), so there's nothing to set up. """ import argparse import json import os import re import shutil import subprocess import sys import urllib.error import urllib.request import uuid from pathlib import Path from urllib.parse import urlsplit RepoDir = Path(__file__).resolve().parent.parent AppImage = RepoDir / "dist" / "SaraSuite-x86_64.AppImage" SharedDir = Path("/mnt/files/Shared/SaraSuite") def Git(*Args: str) -> str: return subprocess.run(["git", *Args], cwd=RepoDir, capture_output=True, text=True, check=True).stdout.strip() def Fail(Message: str) -> None: sys.exit(f"Release.py: {Message}") def Token(Host: str) -> str: Result = subprocess.run( ["git", "credential", "fill"], input=f"protocol=https\nhost={Host}\n\n", capture_output=True, text=True, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) Found = re.search(r"^password=(.+)$", Result.stdout, re.MULTILINE) if not Found: Fail(f"no Gitea token for {Host} in git's credential store; push once so git saves it") return Found[1] def Api(Method: str, Url: str, TokenValue: str, Body: bytes | None = None, ContentType: str = "application/json"): Request = urllib.request.Request(Url, data=Body, method=Method, headers={ "Authorization": f"token {TokenValue}", "Content-Type": ContentType, "Accept": "application/json", # Cloudflare's bot check rejects Python's default User-Agent (error 1010). "User-Agent": "SaraSuite-Release (git/2)", }) try: with urllib.request.urlopen(Request, timeout=300) as Response: return json.loads(Response.read() or b"null") except urllib.error.HTTPError as Error: if Error.code == 404: return None Fail(f"{Method} {Url} failed: {Error.code} {Error.read().decode(errors='replace')[:300]}") def Main() -> None: Parser = argparse.ArgumentParser(description="Publish a SaraSuite release on Gitea.") Parser.add_argument("--notes", default="", help="release notes (default: the commits since the last release)") Args = Parser.parse_args() Version = re.search(r'^AppVersion = "([^"]+)"', (RepoDir / "Core.py").read_text(), re.MULTILINE)[1] Tag = f"v{Version}" # origin is e.g. https://git.srhtnin.garden/sarah/SaraSuite.git Origin = urlsplit(Git("remote", "get-url", "origin")) Owner, Repo = Origin.path.strip("/").removesuffix(".git").split("/")[-2:] ApiBase = f"{Origin.scheme}://{Origin.netloc}/api/v1/repos/{Owner}/{Repo}" if Git("status", "--porcelain"): Fail("there are uncommitted changes; commit (and push) first") Branch = Git("rev-parse", "--abbrev-ref", "HEAD") Git("fetch", "--quiet", "--tags", "origin") Head = Git("rev-parse", "HEAD") if Head != Git("rev-parse", f"origin/{Branch}"): Fail(f"{Branch} isn't pushed (or is behind origin); push first") TokenValue = Token(Origin.netloc) if Api("GET", f"{ApiBase}/releases/tags/{Tag}", TokenValue) is not None: Fail(f"{Tag} is already released; bump AppVersion in Core.py first") Notes = Args.notes if not Notes: Previous = Git("tag", "--list", "v*", "--sort=-v:refname").splitlines() Range = f"{Previous[0]}..HEAD" if Previous else "HEAD" Notes = Git("log", "--format=- %s", Range) or "- First release with self-updates." print(f"== Building SaraSuite {Version}") subprocess.run(["Packaging/BuildAppImage.sh"], cwd=RepoDir, check=True, env={**os.environ, "PATH": f"{Path.home()}/.local/bin:{os.environ['PATH']}"}) print(f"== Creating release {Tag}") Created = Api("POST", f"{ApiBase}/releases", TokenValue, json.dumps({ "tag_name": Tag, "target_commitish": Head, "name": f"SaraSuite {Version}", "body": Notes, }).encode()) print(f"== Uploading {AppImage.name} ({AppImage.stat().st_size // 1_000_000} MB)") Boundary = uuid.uuid4().hex Body = ( f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{AppImage.name}\"\r\n" f"Content-Type: application/octet-stream\r\n\r\n" ).encode() + AppImage.read_bytes() + f"\r\n--{Boundary}--\r\n".encode() Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={AppImage.name}", TokenValue, Body, f"multipart/form-data; boundary={Boundary}") if SharedDir.is_dir(): shutil.copy2(AppImage, SharedDir / AppImage.name) print(f"== Copied to {SharedDir}") Git("fetch", "--quiet", "--tags", "origin") # the new tag, for the next release's notes print(f"== Released {Tag}: {Origin.scheme}://{Origin.netloc}/{Owner}/{Repo}/releases/tag/{Tag}") if __name__ == "__main__": Main()