Public Access
This commit is contained in:
1 parent
c23b057f51
commit
cb31156584
3 files changed
+293
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user