Files
rivencraft/scripts/publish_mod_sites.py
T
2026-10-31 20:46:41 -04:00

284 lines
12 KiB
Python

#!/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)