Files
SaraSuite/Packaging/Release.py
T
2026-10-09 10:38:43 +02:00

123 lines
5.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Publish a SaraSuite release: build the AppImage, create the Gitea release
v<AppVersion> (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()