diff --git a/.gitea/scripts/release.py b/.gitea/scripts/release.py
new file mode 100644
index 0000000..6c29d4f
--- /dev/null
+++ b/.gitea/scripts/release.py
@@ -0,0 +1,149 @@
+"""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"
+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,
+ headers={"Authorization": f"token {self.token}", "Content-Type": content_type})
+ 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": "t5de-release (https://git.t5de.dev/t5de/t5de-renderer, 1)"})
+ 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__)
diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
new file mode 100644
index 0000000..d9fc80a
--- /dev/null
+++ b/.gitea/workflows/ci.yml
@@ -0,0 +1,58 @@
+# Builds the adapter (32-bit) and the renderer process (64-bit), runs the tests, and publishes both programs: pushes to
+# main replace the nightly pre-release, vX.Y.Z tags make release X.Y.Z (.gitea/scripts/release.py).
+#
+# Runs on T5DE's Windows runner (label `windows`, jobs run directly on the machine: it needs Visual Studio's
+# compiler). The checkout lives in a fixed folder there ($T5DE_BUILD_ROOT, set in the runner's config), so Diligent
+# Engine is fetched and built once, not on every run. Never on pull requests: their code would run on that machine.
+name: CI
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: windows
+ defaults:
+ run:
+ shell: powershell
+ steps:
+ - name: Check out
+ env:
+ TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ function git { & git.exe -c "http.extraHeader=Authorization: token $env:TOKEN" @args; if ($LASTEXITCODE) { exit $LASTEXITCODE } }
+ $root = if ($env:T5DE_BUILD_ROOT) { $env:T5DE_BUILD_ROOT } else { $env:GITHUB_WORKSPACE }
+ $src = Join-Path $root ($env:GITHUB_REPOSITORY -replace '/', '-')
+ if (-not (Test-Path (Join-Path $src .git))) { git init -q $src }
+ Set-Location $src
+ git fetch -q --force --tags "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY" "+${env:GITHUB_REF}:refs/remotes/ci/head"
+ git checkout -q --force --detach $env:GITHUB_SHA
+ git clean -q -ffdx -e /build/
+ "T5DE_SRC=$src" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
+
+ - name: Build and test the adapter
+ working-directory: ${{ env.T5DE_SRC }}
+ run: cmake --workflow --preset adapter
+
+ - name: Build the renderer process
+ working-directory: ${{ env.T5DE_SRC }}
+ run: cmake --workflow --preset renderer
+
+ - name: Collect the programs
+ working-directory: ${{ env.T5DE_SRC }}
+ run: |
+ cmake --install build/windows-x86 --config Release --prefix dist
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+ cmake --install build/windows-x64 --config Release --prefix dist
+
+ - name: Publish the release
+ if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
+ working-directory: ${{ env.T5DE_SRC }}
+ env:
+ GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Optional: a Discord webhook URL that nightlies are announced on.
+ DISCORD_NIGHTLY_WEBHOOK: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK }}
+ run: python .gitea/scripts/release.py publish dist