#!/usr/bin/env python3 """Publish a Rivencraft JAR to Gitea, 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" GITEA_API = "https://git.paddedcell.org/api/v1/repos/dnorberg/rivencraft" USER_AGENT = "RivencraftReleaseTool/1.0 (https://git.paddedcell.org/dnorberg/rivencraft)" SITES = ("gitea", "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 == "gitea": account = request_json("https://git.paddedcell.org/api/v1/token", token=f"token {token}") scopes = account.get("scopes", []) if "write:repository" not in scopes and "all" not in scopes: raise RuntimeError("Gitea token is valid but lacks write:repository scope") print(f"Gitea API access works for {account['user']['login']} (write:repository)") elif 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", not_found_ok: bool = False) -> dict | list | None: 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: if error.code == 404 and not_found_ok: return None # 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, *, file_field: str = "file") -> 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_field}\"; 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, } 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 publish_gitea(args: argparse.Namespace) -> None: base_version, suffix = args.version.split("-", 1) if suffix != f"{args.channel}.1": raise RuntimeError("Gitea tag convention requires a version ending in the selected channel and .1") tag = f"V{base_version}-mc{args.minecraft}-{suffix}" # The release must refer to a tag already pushed by the normal Git workflow. remote_tag = subprocess.run( ["git", "ls-remote", "origin", f"refs/tags/{tag}"], text=True, capture_output=True, check=True, ) if not remote_tag.stdout.strip(): raise RuntimeError(f"Push the Gitea tag {tag} before publishing") token = f"token {token_for('gitea')}" release_url = f"{GITEA_API}/releases/tags/{tag}" release = request_json(release_url, token=token, not_found_ok=True) notes = args.changelog.read_text(encoding="utf-8") title = f"Rivencraft {args.minecraft} {args.channel} {args.version}" if release is None: metadata = { "tag_name": tag, "target_commitish": "main", "name": title, "body": notes, "draft": False, "prerelease": args.channel != "release", } release = request_json( f"{GITEA_API}/releases", token=token, payload=json.dumps(metadata).encode(), content_type="application/json", method="POST", ) elif release.get("name") != title or release.get("body", "").strip() != notes.strip(): raise RuntimeError(f"Gitea release {tag} exists with different notes or title; inspect it before retrying") assets = release.get("assets", []) if any(asset.get("name") == args.jar.name for asset in assets): raise RuntimeError(f"Gitea release {tag} already has {args.jar.name}") body, content_type = multipart({}, args.jar, file_field="attachment") attachment = request_json( f"{GITEA_API}/releases/{release['id']}/assets", token=token, payload=body, content_type=content_type, method="POST", ) if attachment.get("name") != args.jar.name or attachment.get("size") != args.jar.stat().st_size: raise RuntimeError("Gitea returned unexpected attachment metadata; inspect the release") print(f"Gitea: {release['html_url']}") 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", "all"), default="all") 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 == "all" else (("modrinth", "curseforge") 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 "gitea" in sites: publish_gitea(args) 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)