diff --git a/docs/PUBLISHING_APIS.md b/docs/PUBLISHING_APIS.md index faa08eb..6c2efb6 100644 --- a/docs/PUBLISHING_APIS.md +++ b/docs/PUBLISHING_APIS.md @@ -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) diff --git a/scripts/publish_mod_sites.py b/scripts/publish_mod_sites.py index 126cbfd..0cd4b10 100644 --- a/scripts/publish_mod_sites.py +++ b/scripts/publish_mod_sites.py @@ -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: