Public Access
This commit is contained in:
1 parent
d6e9a58a24
commit
d9464fad76
2 files changed
+87
-10
No files matched your search
+21
-2
@@ -1,4 +1,4 @@
|
||||
# Publishing Rivencraft with the Modrinth and CurseForge APIs
|
||||
# Publishing Rivencraft with the Gitea, 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
|
||||
@@ -8,6 +8,12 @@ is enabled for it.
|
||||
|
||||
## One-time token setup
|
||||
|
||||
For Gitea, an existing personal access token is sufficient if it has
|
||||
`write:repository` scope. That scope covers release creation and attachment
|
||||
upload; do not grant `all` if a narrower token is available. Create or inspect
|
||||
tokens under [Gitea user settings → Applications](https://git.paddedcell.org/user/settings/applications).
|
||||
The helper checks token identity and scope without displaying the secret.
|
||||
|
||||
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
|
||||
@@ -21,6 +27,7 @@ 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
|
||||
python3 scripts/publish_mod_sites.py store-token gitea
|
||||
```
|
||||
|
||||
The tokens are stored in the desktop Secret Service keyring under
|
||||
@@ -34,6 +41,7 @@ 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
|
||||
python3 scripts/publish_mod_sites.py check-token gitea
|
||||
```
|
||||
|
||||
## Release workflow
|
||||
@@ -54,7 +62,18 @@ For a *new* version only, append `--publish` to upload to both sites. Use
|
||||
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
|
||||
The default destination is now all three sites. The Gitea release step requires
|
||||
the annotated version tag to have been pushed first. Gitea's `Vx.y.z.b-mc26.3-beta.1`
|
||||
tag convention is derived from the version and channel; an existing release
|
||||
with different title or notes is never overwritten. If release creation
|
||||
succeeds but its JAR upload fails, rerun with `--site gitea --publish` to resume
|
||||
only the missing attachment. `--site both` retains the earlier Modrinth and
|
||||
CurseForge-only behavior.
|
||||
|
||||
The helper uses Gitea's documented
|
||||
[`POST /repos/{owner}/{repo}/releases`](https://docs.gitea.com/api/operations/repo-create-release/)
|
||||
and [release attachment endpoint](https://docs.gitea.com/api/operations/repo-create-release-attachment/),
|
||||
plus 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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a Rivencraft JAR to Modrinth and/or CurseForge.
|
||||
"""Publish a Rivencraft JAR to Gitea, Modrinth, and/or CurseForge.
|
||||
|
||||
Preview is the default. Tokens live in the Secret Service keyring, never in git.
|
||||
"""
|
||||
@@ -19,8 +19,9 @@ 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 = ("modrinth", "curseforge")
|
||||
SITES = ("gitea", "modrinth", "curseforge")
|
||||
|
||||
|
||||
def token_for(site: str) -> str:
|
||||
@@ -59,7 +60,13 @@ def store_token(site: str) -> None:
|
||||
|
||||
def check_token(site: str) -> None:
|
||||
token = token_for(site)
|
||||
if site == "modrinth":
|
||||
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:
|
||||
@@ -81,7 +88,7 @@ def check_token(site: str) -> None:
|
||||
|
||||
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:
|
||||
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
|
||||
@@ -92,12 +99,14 @@ def request_json(url: str, *, token: str | None = None, token_header: str = "Aut
|
||||
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) -> tuple[bytes, str]:
|
||||
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():
|
||||
@@ -108,7 +117,7 @@ def multipart(fields: dict[str, str], jar: Path) -> tuple[bytes, str]:
|
||||
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(),
|
||||
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}"
|
||||
@@ -171,6 +180,53 @@ def publish_curseforge(metadata: dict, jar: Path) -> None:
|
||||
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)
|
||||
@@ -186,7 +242,7 @@ def main() -> None:
|
||||
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("--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":
|
||||
@@ -204,13 +260,15 @@ def main() -> None:
|
||||
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,)
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user