"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]>
168 lines
6.1 KiB
Python
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()
|