#!/usr/bin/env python3 """Publish a SaraSuite release: build the AppImage (Linux) and SaraSuite.exe (Windows, built under Wine by BuildExe.sh), create the Gitea release v (from Core.py) with both attached, and copy them to the Shared folder. Running SaraSuites then offer to update themselves (SelfUpdate.py), each to its own platform's file. 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 # Each running SaraSuite downloads the one for its own platform (SelfUpdate.AssetName). Builds = [ ("Packaging/BuildAppImage.sh", RepoDir / "dist" / "SaraSuite-x86_64.AppImage"), ("Packaging/BuildExe.sh", RepoDir / "dist" / "SaraSuite.exe"), ] 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." for Script, Output in Builds: print(f"== Building {Output.name} {Version}") subprocess.run([Script], 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()) for _Script, Output in Builds: print(f"== Uploading {Output.name} ({Output.stat().st_size // 1_000_000} MB)") Boundary = uuid.uuid4().hex Body = ( f"--{Boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{Output.name}\"\r\n" f"Content-Type: application/octet-stream\r\n\r\n" ).encode() + Output.read_bytes() + f"\r\n--{Boundary}--\r\n".encode() Api("POST", f"{ApiBase}/releases/{Created['id']}/assets?name={Output.name}", TokenValue, Body, f"multipart/form-data; boundary={Boundary}") if SharedDir.is_dir(): for _Script, Output in Builds: shutil.copy2(Output, SharedDir / Output.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()