Files
SaraSuite/Packaging/Release.py
T

132 lines
5.5 KiB
Python
Executable File

#!/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<AppVersion> (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()