diff --git a/.gitea/scripts/publish_modrinth.py b/.gitea/scripts/publish_modrinth.py new file mode 100644 index 0000000..5487259 --- /dev/null +++ b/.gitea/scripts/publish_modrinth.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Publish the jars attached to a Gitea release to Modrinth. + +Creates one Modrinth version per jar (saturn-client-+.jar), for +that Minecraft version, with Fabric API as a required dependency and the Gitea +release notes as the changelog. Versions that already exist on Modrinth are +skipped, so the script is safe to re-run. + +Environment: + TAG release tag, e.g. 0.1.1-beta + SERVER_URL public Gitea URL, e.g. https://git.selimaj.dev + REPO owner/name, e.g. saturnclientmc/saturnclient + MODRINTH_TOKEN Modrinth PAT with the "Create versions" scope + DRY_RUN "true" to check everything and print what would be + published without uploading (no token needed) +""" + +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + +MODRINTH_API = "https://api.modrinth.com/v2" +MODRINTH_PROJECT_ID = "i6JDSY9x" # saturnclient +FABRIC_API_PROJECT_ID = "P7dR8mSH" +USER_AGENT = "saturnclientmc/saturnclient release workflow (https://git.selimaj.dev/saturnclientmc/saturnclient)" + +JAR_NAME = re.compile(r"^saturn-client-(?P.+)\+(?P\d+\.\d+(?:\.\d+)?)\.jar$") + + +def request(url, data=None, headers=None, method=None): + req = urllib.request.Request(url, data=data, method=method) + req.add_header("User-Agent", USER_AGENT) + for key, value in (headers or {}).items(): + req.add_header(key, value) + with urllib.request.urlopen(req, timeout=300) as res: + return res.read() + + +def get_json(url, headers=None): + return json.loads(request(url, headers=headers)) + + +def version_type(tag): + tag = tag.lower() + if "alpha" in tag: + return "alpha" + if "beta" in tag or "rc" in tag or "pre" in tag: + return "beta" + return "release" + + +def mc_sort_key(mc): + return tuple(int(part) for part in mc.split(".")) + + +def multipart(fields, file_field, file_name, file_bytes): + boundary = uuid.uuid4().hex + parts = [] + for name, value in fields.items(): + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n' + f"Content-Type: application/json\r\n\r\n{value}\r\n".encode() + ) + parts.append( + f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{file_name}"\r\n' + f"Content-Type: application/java-archive\r\n\r\n".encode() + ) + parts.append(file_bytes) + parts.append(f"\r\n--{boundary}--\r\n".encode()) + return b"".join(parts), f"multipart/form-data; boundary={boundary}" + + +def main(): + tag = os.environ["TAG"] + server_url = os.environ["SERVER_URL"].rstrip("/") + repo = os.environ["REPO"] + dry_run = os.environ.get("DRY_RUN", "").lower() == "true" + token = os.environ.get("MODRINTH_TOKEN", "") + + if not token and not dry_run: + sys.exit("MODRINTH_TOKEN is not set. Add it as a repository secret (Settings → Actions → Secrets).") + + try: + release = get_json(f"{server_url}/api/v1/repos/{repo}/releases/tags/{urllib.parse.quote(tag)}") + except urllib.error.HTTPError as e: + if e.code == 404: + sys.exit(f"No Gitea release with tag {tag}.") + raise + changelog = release.get("body") or "" + + jars = [] + for asset in release.get("assets", []): + match = JAR_NAME.match(asset["name"]) + if match: + jars.append((match["mc"], match["version"], asset["name"])) + + if not jars: + sys.exit(f"Release {tag} has no saturn-client-*.jar assets. Run the Release workflow first.") + + for mc, version, name in jars: + if version != tag.removeprefix("v"): + sys.exit(f"{name} is version {version}, but the release tag is {tag}.") + + known_game_versions = {g["version"] for g in get_json(f"{MODRINTH_API}/tag/game_version")} + unknown = sorted({mc for mc, _, _ in jars} - known_game_versions) + if unknown: + sys.exit(f"Modrinth doesn't know these Minecraft versions: {', '.join(unknown)}") + + existing = {v["version_number"] for v in get_json(f"{MODRINTH_API}/project/{MODRINTH_PROJECT_ID}/version")} + kind = version_type(tag) + failed = [] + + for mc, version, name in sorted(jars, key=lambda jar: mc_sort_key(jar[0])): + version_number = f"{version}+{mc}" + + if version_number in existing: + print(f"skip {version_number}: already on Modrinth") + continue + + # Gitea's own download URLs use its internal ROOT_URL, so build a public one. + download_url = f"{server_url}/{repo}/releases/download/{urllib.parse.quote(tag)}/{urllib.parse.quote(name)}" + + data = { + "project_id": MODRINTH_PROJECT_ID, + "name": f"Saturn Client {version_number}", + "version_number": version_number, + "changelog": changelog, + "dependencies": [{"project_id": FABRIC_API_PROJECT_ID, "dependency_type": "required"}], + "game_versions": [mc], + "version_type": kind, + "loaders": ["fabric"], + "featured": False, + "status": "listed", + "file_parts": ["file"], + "primary_file": "file", + } + + if dry_run: + size = request(download_url, headers={"Range": "bytes=0-0"}) + print(f"would publish {version_number} ({kind}, Minecraft {mc}) from {download_url} [reachable: {len(size) > 0}]") + continue + + try: + jar_bytes = request(download_url) + body, content_type = multipart({"data": json.dumps(data)}, "file", name, jar_bytes) + request( + f"{MODRINTH_API}/version", + data=body, + headers={"Authorization": token, "Content-Type": content_type}, + method="POST", + ) + print(f"publish {version_number}: done ({len(jar_bytes) / 1_000_000:.1f} MB)") + except urllib.error.HTTPError as e: + print(f"publish {version_number}: FAILED {e.code} {e.read().decode(errors='replace')}", file=sys.stderr) + failed.append(version_number) + + if failed: + sys.exit(f"Failed to publish: {', '.join(failed)}") + + +if __name__ == "__main__": + main() diff --git a/.gitea/workflows/modrinth.yml b/.gitea/workflows/modrinth.yml new file mode 100644 index 0000000..c71c999 --- /dev/null +++ b/.gitea/workflows/modrinth.yml @@ -0,0 +1,40 @@ +name: Publish to Modrinth + +# Publishes the jars already attached to a Gitea release (built by the Release +# workflow) to Modrinth, one version per Minecraft version. Run it manually +# after smoke-testing the release. It doesn't rebuild anything, and versions +# already on Modrinth are skipped, so re-running is safe. +# +# Needs a MODRINTH_TOKEN repository secret: a Modrinth PAT with the +# "Create versions" scope. +on: + workflow_dispatch: + inputs: + tag: + description: Tag of the Gitea release to publish + required: true + dry_run: + description: Only check and print what would be published + type: boolean + default: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Publish to Modrinth + env: + TAG: ${{ inputs.tag }} + DRY_RUN: ${{ inputs.dry_run }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + run: python .gitea/scripts/publish_modrinth.py