Public Access
284 lines
12 KiB
Python
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)
|