diff --git a/.gitignore b/.gitignore index f0a29ac..a6e5c29 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,10 @@ bin/ ### Mac OS ### .DS_Store +### Python tools ### +__pycache__/ +*.pyc + ### Minecraft Modding ### run/ logs/ diff --git a/docs/PUBLISHING_APIS.md b/docs/PUBLISHING_APIS.md new file mode 100644 index 0000000..faa08eb --- /dev/null +++ b/docs/PUBLISHING_APIS.md @@ -0,0 +1,63 @@ +# Publishing Rivencraft with the Modrinth and CurseForge APIs + +The local helper is [`scripts/publish_mod_sites.py`](../scripts/publish_mod_sites.py). +It previews a release unless `--publish` is explicitly supplied. The current +configuration is for the Minecraft **26.3** artifact only. The 1.21.1 line needs +its required external dependencies mapped on both sites before API publishing +is enabled for it. + +## One-time token setup + +Create a [Modrinth personal access token](https://docs.modrinth.com/api/#authentication) +in account settings with `VERSION_CREATE` (upload) and `USER_READ` (read-only +credential check) scopes. Create a +[CurseForge Upload API token](https://support.curseforge.com/support/solutions/articles/9000197321) +from the author's API Tokens page. Treat both as passwords: never put them in +git, chat, a release note, or a command-line argument. + +In a terminal at the repository root, run these one at a time and paste the +corresponding token into the hidden prompt: + +```sh +python3 scripts/publish_mod_sites.py store-token modrinth +python3 scripts/publish_mod_sites.py store-token curseforge +``` + +The tokens are stored in the desktop Secret Service keyring under +`application=rivencraft-publishing`, separately by site. The script also accepts +`RIVENCRAFT_MODRINTH_TOKEN` or `RIVENCRAFT_CURSEFORGE_TOKEN` environment variables, +but these take precedence over keyring entries and should not be put in shell +history or startup files. + +Verify the credentials without uploading anything: + +```sh +python3 scripts/publish_mod_sites.py check-token modrinth +python3 scripts/publish_mod_sites.py check-token curseforge +``` + +## Release workflow + +Build the JAR, write a Markdown changelog, and preview the metadata: + +```sh +python3 scripts/publish_mod_sites.py release \ + --jar ports/mc263/build/libs/rivencraft-26.3-1.0.1.32-beta.1.jar \ + --changelog ports/mc263/RELEASE_NOTES_1.0.1.32-beta.1.md \ + --version 1.0.1.32-beta.1 \ + --name 'Rivencraft 1.0.1.32-beta.1 — NeoForge 26.3' \ + --minecraft 26.3 --java 25 --channel beta +``` + +For a *new* version only, append `--publish` to upload to both sites. Use +`--site modrinth` or `--site curseforge` to target one site. Modrinth rejects an +existing version number; CurseForge may not, so check its file list before any +retry after a partial failure. CurseForge approval may take additional time. + +The helper uses Modrinth's documented multipart +[`POST /version`](https://docs.modrinth.com/api/operations/createversion/) and +CurseForge's documented +[`POST /api/projects/{projectId}/upload-file`](https://support.curseforge.com/support/solutions/articles/9000197321) +with the JAR, changelog, version channel, NeoForge, Minecraft version, Java +version, and client/server environment. It sets no external dependencies for +26.3, which uses Rivencraft's first-party Age backend. diff --git a/scripts/publish_mod_sites.py b/scripts/publish_mod_sites.py new file mode 100644 index 0000000..72e889e --- /dev/null +++ b/scripts/publish_mod_sites.py @@ -0,0 +1,226 @@ +#!/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)