CI / build (push) Successful in 45s
The client sends it every HTTP request and its IMQ connection; it makes them with current TLS and certificates, upgrades IMVU's plain-HTTP hosts to HTTPS, and refuses IMVU's reporting endpoints. CI tests it and publishes t5de-proxy.exe as nightlies and tagged releases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
154 lines
7.2 KiB
Python
154 lines
7.2 KiB
Python
"""Publish the proxy. Run by ci.yml.
|
|
|
|
release.py version print version and channel
|
|
release.py publish <dir> publish the program 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 = ("t5de-proxy.exe",)
|
|
NIGHTLY_TAG = "nightly"
|
|
USER_AGENT = "t5de-release (https://git.t5de.dev/t5de/t5de-proxy, 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 proxy for IMVU Classic: the client connects to IMVU through it. T5DE's setup installs a
|
|
pinned release with its T5DE Proxy patch. To try this build instead, copy t5de-proxy.exe into a T5DE install's
|
|
folder (with that patch) while the client is closed.
|
|
|
|
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 Proxy {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"Proxy 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__)
|