Add Gitea API release publishing to CLI helper
Build / build (push) Canceled after 0s

This commit is contained in:
dnorberg committed 2026-10-31 20:46:41 -04:00
1 parent d6e9a58a24
commit d9464fad76
2 files changed
+87 -10

No files matched your search

+21 -2
View File
@@ -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)
+66 -8
View File
@@ -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: