"""Publish the renderer's programs. Run by ci.yml. release.py version print version and channel release.py publish publish the programs in dir: a release for a vX.Y.Z tag, else the rolling nightly A vX.Y.Z tag is release X.Y.Z. Other builds are nightlies, "X.Y.Z-nightly.N+commit": N commits after the last release tag (0.0.0 before the first). The nightly pre-release (tag `nightly`) is replaced on every push to main. The same scheme as T5DE's own releases (t5de's .gitea/scripts/release.py). """ import hashlib import json import os import re import subprocess import sys import urllib.error import urllib.request import uuid from pathlib import Path ASSETS = ("t5deRenderEngine.dll", "t5de-renderer.exe") NIGHTLY_TAG = "nightly" USER_AGENT = "t5de-release (https://git.t5de.dev/t5de/t5de-renderer, 1)" RELEASE_TAG = re.compile(r"v(\d+)\.(\d+)\.(\d+)") def git(*args: str) -> str: return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip() def version() -> dict[str, str]: ref_name = os.environ.get("GITHUB_REF_NAME", "") if os.environ.get("GITHUB_REF_TYPE") == "tag" and RELEASE_TAG.fullmatch(ref_name): return {"version": ref_name[1:], "channel": "release", "tag": ref_name} try: last = git("describe", "--tags", "--abbrev=0", "--match", "v[0-9]*.[0-9]*.[0-9]*") count = int(git("rev-list", "--count", f"{last}..HEAD")) base = last[1:] except subprocess.CalledProcessError: # no release yet count, base = int(git("rev-list", "--count", "HEAD")), "0.0.0" commit = git("rev-parse", "--short=7", "HEAD") return {"version": f"{base}-nightly.{count}+{commit}", "channel": "nightly", "tag": NIGHTLY_TAG} class Gitea: def __init__(self): self.api = f"{os.environ['GITHUB_SERVER_URL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}" self.token = os.environ["GITEA_TOKEN"] def request(self, method: str, path: str, body: dict | None = None, data: bytes | None = None, content_type: str = "application/json"): if body is not None: data = json.dumps(body).encode() req = urllib.request.Request(self.api + path, method=method, data=data, # Cloudflare in front of the server refuses Python's default User-Agent. headers={"Authorization": f"token {self.token}", "Content-Type": content_type, "User-Agent": USER_AGENT}) try: with urllib.request.urlopen(req, timeout=120) as response: text = response.read() return json.loads(text) if text else None except urllib.error.HTTPError as e: if e.code == 404 and method in ("GET", "DELETE"): return None raise SystemExit(f"{method} {path}: {e.code} {e.read().decode(errors='replace')}") def upload(self, release_id: int, name: str, content: bytes): boundary = uuid.uuid4().hex data = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n" f"Content-Type: application/octet-stream\r\n\r\n").encode() + content + f"\r\n--{boundary}--\r\n".encode() return self.request("POST", f"/releases/{release_id}/assets?name={name}", data=data, content_type=f"multipart/form-data; boundary={boundary}") def notes(info: dict[str, str], files: dict[str, bytes]) -> str: nightly = ("A nightly build of the latest commit, for testing; it's replaced by the next one.\n\n" if info["channel"] == "nightly" else "") hashes = "\n".join(f"`{name}` sha256: `{hashlib.sha256(content).hexdigest()}`" for name, content in files.items()) return f"""{nightly}T5DE's renderer for IMVU Classic: copy both files into a T5DE install's folder, and choose OpenGL in the client's Settings > Advanced (with T5DE's renderer patch). Unofficial: not made by or affiliated with IMVU. Use at your own risk. Version {info['version']} · commit {os.environ.get('GITHUB_SHA', git('rev-parse', 'HEAD'))} {hashes} """ def publish(folder: str): info = version() files = {name: (Path(folder) / name).read_bytes() for name in ASSETS} gitea = Gitea() previous = None if info["channel"] == "nightly": old = gitea.request("GET", f"/releases/tags/{NIGHTLY_TAG}") if old: previous = old["target_commitish"] gitea.request("DELETE", f"/releases/{old['id']}") gitea.request("DELETE", f"/tags/{NIGHTLY_TAG}") release = gitea.request("POST", "/releases", { "tag_name": NIGHTLY_TAG, "target_commitish": os.environ["GITHUB_SHA"], "name": f"Nightly {info['version']}", "body": notes(info, files), "prerelease": True}) else: release = gitea.request("GET", f"/releases/tags/{info['tag']}") fields = {"name": f"T5DE Renderer {info['version']}", "body": notes(info, files), "prerelease": False} if release: # a rerun: refresh it release = gitea.request("PATCH", f"/releases/{release['id']}", fields) for asset in release.get("assets") or []: if asset["name"] in files: gitea.request("DELETE", f"/releases/{release['id']}/assets/{asset['id']}") else: release = gitea.request("POST", "/releases", {"tag_name": info["tag"], **fields}) for name, content in files.items(): gitea.upload(release["id"], name, content) print(f"Published {release['html_url']}") if info["channel"] == "nightly" and os.environ.get("DISCORD_NIGHTLY_WEBHOOK"): announce(os.environ["DISCORD_NIGHTLY_WEBHOOK"], info, release, previous) def announce(webhook: str, info: dict[str, str], release: dict, previous: str | None): """Post the release to a Discord channel. Failing to doesn't fail the release.""" commits = [] if previous: try: commits = git("log", "--format=%h %s", "--no-merges", f"{previous}..HEAD").splitlines() except subprocess.CalledProcessError: # the previous nightly's commit is gone (history rewritten) pass shown = commits[:10] + ([f"and {len(commits) - 10} more"] if len(commits) > 10 else []) description = f"[Download]({release['html_url']})" if shown: description += "\n\n" + "\n".join(f"• {line}" for line in shown) body = {"embeds": [{"title": f"Renderer Nightly {info['version']}", "url": release["html_url"], "description": description[:4000], "color": 0xFFD200}]} req = urllib.request.Request(webhook, method="POST", data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "User-Agent": USER_AGENT}) try: urllib.request.urlopen(req, timeout=30).close() print("Announced on Discord") except (urllib.error.URLError, TimeoutError) as e: print(f"warning: couldn't post to Discord: {e}") if __name__ == "__main__": if sys.argv[1:] == ["version"]: for key, value in version().items(): print(f"{key}={value}") elif len(sys.argv) == 3 and sys.argv[1] == "publish": publish(sys.argv[2]) else: raise SystemExit(__doc__)