Public Access
This commit is contained in:
1 parent
c23b057f51
commit
cb31156584
3 files changed
+293
No files matched your search
@@ -32,6 +32,10 @@ bin/
|
|||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
### Python tools ###
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
### Minecraft Modding ###
|
### Minecraft Modding ###
|
||||||
run/
|
run/
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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