#!/usr/bin/env python3 """Publish a Rivencraft JAR to Modrinth and/or CurseForge. Preview is the default. Tokens live in the Secret Service keyring, never in git. """ import argparse import getpass import json import mimetypes import os from pathlib import Path import secrets import subprocess import sys import urllib.error import urllib.request MODRINTH_PROJECT = "GH2xfZJ8" CURSEFORGE_PROJECT = "1766326" USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)" SITES = ("modrinth", "curseforge") def token_for(site: str) -> str: environment = os.environ.get(f"RIVENCRAFT_{site.upper()}_TOKEN", "").strip() if environment: return environment result = subprocess.run( ["secret-tool", "lookup", "application", "rivencraft-publishing", "site", site], text=True, capture_output=True, check=False, ) if result.returncode: raise RuntimeError(f"Could not read {site} token from the system keyring") token = result.stdout.strip() if not token: raise RuntimeError(f"No {site} token found; run `store-token {site}` first") return token def store_token(site: str) -> None: token = getpass.getpass(f"Paste the {site} API token (input hidden): ").strip() if not token: raise RuntimeError("Empty token; nothing was stored") subprocess.run( [ "secret-tool", "store", "--label", f"Rivencraft {site} publishing token", "application", "rivencraft-publishing", "site", site, ], input=token + "\n", text=True, check=True, ) print(f"Stored {site} token in the system keyring") def check_token(site: str) -> None: token = token_for(site) if site == "modrinth": try: account = request_json("https://api.modrinth.com/v2/user", token=token) except RuntimeError as error: raise RuntimeError( "Modrinth check failed. The token needs USER_READ for this " "read-only check, plus VERSION_CREATE for publishing. " f"API response: {error}" ) from None print(f"Modrinth API access works for {account['username']}") else: versions = request_json( "https://www.curseforge.com/api/game/versions", token=token, token_header="X-Api-Token", ) if not isinstance(versions, list): raise RuntimeError("CurseForge returned an unexpected response") print("CurseForge API token accepted") def request_json(url: str, *, token: str | None = None, token_header: str = "Authorization", payload: bytes | None = None, content_type: str | None = None, method: str = "GET") -> dict | list: headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} if token: headers[token_header] = token if content_type: headers["Content-Type"] = content_type request = urllib.request.Request(url, data=payload, headers=headers, method=method) try: with urllib.request.urlopen(request, timeout=120) as response: return json.load(response) except urllib.error.HTTPError as error: # Error bodies can contain server details, but never echo tokens. detail = error.read(600).decode("utf-8", "replace") raise RuntimeError(f"{url}: HTTP {error.code}: {detail}") from None def multipart(fields: dict[str, str], jar: Path) -> tuple[bytes, str]: boundary = "rivencraft-" + secrets.token_hex(16) chunks = [] for name, value in fields.items(): chunks.extend(( f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n".encode(), value.encode(), b"\r\n", )) filename = jar.name.replace('"', "") mime = mimetypes.guess_type(filename)[0] or "application/java-archive" chunks.extend(( f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode(), jar.read_bytes(), b"\r\n", f"--{boundary}--\r\n".encode(), )) return b"".join(chunks), f"multipart/form-data; boundary={boundary}" def release_metadata(args: argparse.Namespace) -> tuple[dict, dict]: changelog = args.changelog.read_text(encoding="utf-8") if not changelog.strip(): raise RuntimeError("Changelog is empty") modrinth = { "name": args.name, "version_number": args.version, "changelog": changelog, "dependencies": [], "game_versions": [args.minecraft], "version_type": args.channel, "loaders": ["neoforge"], "featured": False, "status": "listed", "project_id": MODRINTH_PROJECT, "file_parts": ["file"], "primary_file": "file", "environment": "client_and_server", } curseforge = { "changelog": changelog, "changelogType": "markdown", "displayName": args.name, "gameVersionNames": [args.minecraft, "NeoForge", "Client", "Server", f"Java {args.java}"], "releaseType": args.channel, "isMarkedForManualRelease": False, "relations": {"projects": []}, } return modrinth, curseforge def publish_modrinth(metadata: dict, jar: Path) -> None: token = token_for("modrinth") versions = request_json( f"https://api.modrinth.com/v2/project/{MODRINTH_PROJECT}/version", token=token, ) if any(version.get("version_number") == metadata["version_number"] for version in versions): raise RuntimeError(f"Modrinth version {metadata['version_number']} already exists") body, content_type = multipart({"data": json.dumps(metadata)}, jar) result = request_json( "https://api.modrinth.com/v2/version", token=token, payload=body, content_type=content_type, method="POST", ) print(f"Modrinth: https://modrinth.com/mod/rivencraft/version/{result['version_number']}") def publish_curseforge(metadata: dict, jar: Path) -> None: token = token_for("curseforge") body, content_type = multipart({"metadata": json.dumps(metadata)}, jar) result = request_json( f"https://www.curseforge.com/api/projects/{CURSEFORGE_PROJECT}/upload-file", token=token, token_header="X-Api-Token", payload=body, content_type=content_type, method="POST", ) print(f"CurseForge: https://www.curseforge.com/minecraft/mc-mods/rivencraft/files/{result['id']} (pending approval)") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="command", required=True) store = sub.add_parser("store-token", help="Securely save a token in the system keyring") store.add_argument("site", choices=SITES) check = sub.add_parser("check-token", help="Verify stored token with a read-only API call") check.add_argument("site", choices=SITES) publish = sub.add_parser("release", help="Preview or publish a JAR release") publish.add_argument("--jar", type=Path, required=True) publish.add_argument("--changelog", type=Path, required=True) publish.add_argument("--version", required=True) publish.add_argument("--name", required=True) publish.add_argument("--minecraft", required=True) publish.add_argument("--java", required=True) publish.add_argument("--channel", choices=("alpha", "beta", "release"), required=True) publish.add_argument("--site", choices=(*SITES, "both"), default="both") publish.add_argument("--publish", action="store_true", help="Actually upload; omitted by default") args = parser.parse_args() if args.command == "store-token": store_token(args.site) return if args.command == "check-token": check_token(args.site) return if not args.jar.is_file() or args.jar.suffix != ".jar": raise RuntimeError(f"JAR does not exist: {args.jar}") if not args.changelog.is_file(): raise RuntimeError(f"Changelog does not exist: {args.changelog}") if args.minecraft != "26.3": raise RuntimeError("Only the 26.3 line is configured; 1.21.1 needs required dependency relations") if args.jar.name != f"rivencraft-{args.minecraft}-{args.version}.jar": raise RuntimeError("JAR filename does not match Minecraft and release versions") modrinth, curseforge = release_metadata(args) sites = SITES if args.site == "both" else (args.site,) print(f"JAR: {args.jar.resolve()} ({args.jar.stat().st_size} bytes)") print(f"Release: {args.version} | {args.channel} | Minecraft {args.minecraft} | NeoForge | Java {args.java}") print(f"Destinations: {', '.join(sites)}") if not args.publish: print("PREVIEW ONLY: add --publish to upload") return if "modrinth" in sites: publish_modrinth(modrinth, args.jar) if "curseforge" in sites: publish_curseforge(curseforge, args.jar) if __name__ == "__main__": try: main() except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: print(f"Publishing failed: {error}", file=sys.stderr) sys.exit(1)