Files
saturnclient/.gitea/scripts/publish_modrinth.py
T
selimaj-devandclaude c54b5ae6d7 Add a manual workflow that publishes a Gitea release to Modrinth
"Publish to Modrinth" (workflow_dispatch, tag + dry_run inputs) takes the
jars the Release workflow attached to a Gitea release and creates one
Modrinth version per jar: version_number <version>+<mc>, that Minecraft
version, fabric loader, Fabric API as a required dependency, the release
notes as the changelog, and alpha/beta/release from the tag. It doesn't
rebuild, skips versions already on Modrinth, and downloads jars through
the public URL (Gitea's reported URLs use its internal ROOT_URL).

Needs a MODRINTH_TOKEN secret with the "Create versions" scope.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-25 19:11:41 +02:00

168 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Publish the jars attached to a Gitea release to Modrinth.
Creates one Modrinth version per jar (saturn-client-<version>+<mc>.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<version>.+)\+(?P<mc>\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()